OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social commits / ad56f01

Add hyper-social-web POSSE client, AT Protocol bridge design, and community moderation spec
- hyper-social-web: unified timeline + cross-posting for Mastodon, Bluesky, Instagram - bridge-envelope: add ATPROTO (7) and INSTAGRAM (8) platform IDs - docs: AT Protocol bridge integration design, community moderation spec Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ad56f015ce25460c2b04b371f441ee453d8b9784
by plankatron <fleeky@gmail.com> · 2026-03-24T17:31:12.000Z

Changed files (18) +4755 -1

bridge-envelope/lib/envelope.js +3 -1
……@@ -20,7 +20,9 @@ const PLATFORMS = {
2020 MATRIX: 3,
2121 MASTODON: 4,
2222 EMAIL: 5,
23 ANDROID: 6
23 ANDROID: 6,
24 ATPROTO: 7,
25 INSTAGRAM: 8
2426}
2527
2628// Reverse maps for display
docs/ATPROTO-BRIDGE.md +192 -0
……@@ -0,0 +1,192 @@
1# AT Protocol Bridge
2
3Integration design for `bridge-atproto` — AT Protocol (Bluesky) as a Tier 2 bridge in hyper-social.
4
5## AT Protocol Ecosystem (2026)
6
7### Self-Hosting is Production-Ready
8
9- Official PDS runs on a **Raspberry Pi 3 B+** (1 core, 1GB RAM)
10- **Tranquil PDS** (Rust, single binary + Postgres) — passkeys, 2FA, SSO, `did:web`, granular OAuth scopes
11- **rsky** (Rust, Blacksky) — full independent relay + AppView + PDS at network scale
12- Cloudflare Tunnels eliminate static IP requirements
13- **IndieSky** working group (funded $50K by Free Our Feeds) coordinating independent infrastructure
14
15### Relay Costs Collapsed
16
17**Sync v1.1** ("inductive firehose" / MST inversion) removed the requirement for relays to cache all network data locally:
18
19- Full-network relay: **$34/month** (2 vCPU, 12GB RAM, 30 Mbps)
20- Community members running relays on Raspberry Pis and $19/month VPS boxes
21- Rainbow fan-out servers re-broadcast firehose to many clients
22
23### The "No AppView" Path
24
25- **Constellation** (Microcosm) — backlink index for the entire AT Proto firehose on a **Raspberry Pi 4B + SSD**, <2GB/day disk
26- **Red Dwarf** — Bluesky client using **zero AppView**, direct PDS queries + Constellation
27- Proves you don't need $500K infrastructure for a functional client
28
29### Alternative PDS Implementations
30
31| Implementation | Language | Storage | Notable Features |
32|---------------|----------|---------|-----------------|
33| Reference PDS | TypeScript | SQLite | Official, Docker or bare metal |
34| Tranquil PDS | Rust | Postgres | Passkeys, 2FA, SSO, did:web, granular OAuth |
35| rsky-pds | Rust | Postgres + S3 | Part of full Blacksky stack |
36| Cocoon | Go | — | Community PDS |
37
38### Protocol Developments
39
40- **Auth Scopes** — granular OAuth permissions per Lexicon type
41- **Lexicon publishing on-protocol** — schemas discoverable via DNS-like lookup
42- **IETF standardization** — working group charter published January 2026
43- **Private/shared data** — next major protocol component (watching MLS at IETF)
44- Relays explicitly optional — clients can connect directly to PDSes via WebSocket
45
46### P2P Hybrid Projects
47
48- **Roomy** — AT Proto for identity/discovery + Automerge CRDTs for P2P data sync (local-first, heading toward full P2P)
49- **Tangled** — social Git forge on AT Proto, self-hostable "knots", raised $4.5M (GitHub's ex-CEO backing)
50- Nobody has built AT Proto over Hypercore/Hyperswarm yet
51
52### Data Model Compatibility
53
54AT Proto repos use DAG-CBOR, CID links, CAR files — the same IPLD primitives as IPFS. Repositories are content-addressed Merkle Search Trees with signed commits. This maps naturally to Hypercore/Hyperbee storage.
55
56## Integration Design
57
58### Approach: bridge-atproto (Tier 2 Bridge)
59
60Same pattern as bridge-irc, bridge-discord, bridge-mastodon. Independently deployable, composes through the hub.
61
62```
63bridge-atproto/
64├── lib/
65│ ├── atproto-client.js # AT Proto PDS connection (XRPC/WebSocket)
66│ ├── keet-client.js # Keet room connection (reuse from other bridges)
67│ ├── adapter.js # AT Proto record ↔ bridge-envelope conversion
68│ ├── router.js # Message routing rules
69│ └── bridge.js # Orchestrator
70├── test/
71│ ├── adapter.test.js
72│ └── router.test.js
73├── index.js
74└── package.json
75```
76
77### Message Mapping
78
79```
80AT Proto post (app.bsky.feed.post) ←→ bridge-envelope ←→ Keet room message
81AT Proto like (app.bsky.feed.like) ←→ bridge-envelope ←→ Keet room reaction
82AT Proto follow (app.bsky.graph.follow) → bridge-envelope → Keet room notification
83AT Proto reply thread ←→ bridge-envelope ←→ Keet room thread
84```
85
86### Credentials
87
88bridge-vault stores AT Proto OAuth tokens or app passwords. Same encrypted Hyperbee-backed storage as other bridges.
89
90### Architecture Alignment
91
92| Concept | Hyper-Social | AT Protocol |
93|---------|-------------|-------------|
94| Data unit | Room message (`_hs:*` JSON) | Record (Lexicon-typed DAG-CBOR) |
95| Schema | Ad-hoc `_hs:` prefixed types | Formal Lexicons (NSIDs) |
96| Multi-writer | Autobase (keet-core) | MST repo per user + relay aggregation |
97| Identity | Keet keypair (ed25519) | DID (`did:plc`, `did:web`, `did:key`) |
98| Discovery | Hyperswarm DHT | DNS + DID resolution |
99| Indexing | MI/clustering on room membership | Relay firehose + AppView/Constellation |
100
101### Identity Bridge
102
103Both systems use ed25519 keypairs. The identity graph (Phase 6) can link them:
104
105```
106Keet keypair ←→ did:key (same ed25519 curve)
107Room membership ←→ AT Proto social graph
108_hs:identity-link ←→ Bidirectional attestation
109```
110
111Tighter than IRC/Discord/Matrix identity linking, which is username-only mapping.
112
113## Deeper Integration Paths
114
115These grow out of the bridge if there's a reason — none are prerequisites.
116
117### hyper-feed as AT Proto Client
118
119hyper-feed subscribes to AT Proto PDSes directly (relays are optional):
120
121- Direct PDS queries for posts/profiles (Red Dwarf architecture)
122- Constellation-style backlink indexing on Autobase (community-scale)
123- Unified timeline: Keet room messages + AT Proto posts
124- Runs on a Raspberry Pi
125
126### Lexicons as Room Metadata Schemas
127
128Publish hyper-social data types as AT Proto Lexicons:
129
130```javascript
131// Current: ad-hoc room metadata
132{ type: '_hs:room-meta', schema: 'wiki', title: '...' }
133
134// With Lexicons: formally typed, interoperable
135{ type: '_hs:room-meta', lexicon: 'social.hypersocial.wiki.page', ... }
136```
137
138Wiki pages, kanban cards, and feed posts become first-class AT Proto records discoverable by any AT Proto client.
139
140### Hyperbee-Backed PDS
141
142Store AT Proto repos directly in Hyperbee. MST keys map to sorted Hyperbee keys:
143
144```
145Hyperbee key: "app.bsky.feed.post/3k..." → value: DAG-CBOR record
146Hyperbee key: "app.bsky.graph.follow/3k..." → value: DAG-CBOR record
147```
148
149Replication over Hyperswarm instead of HTTPS WebSocket sync. HTTP gateway bridges to vanilla AT Proto network for federation.
150
151### Full Topology
152
153```
154 ┌─────────────┐
155Keet rooms ────────►│ hyper-social │◄──────── AT Proto PDSes
156IRC channels ──────►│ (hub) │◄──────── Mastodon
157Discord ───────────►│ │◄──────── Matrix
158Email ─────────────►│ │
159 └──────┬──────┘
160 │
161 Autobase index
162 (community-scale)
163```
164
165Every bridge enriches the same social graph. MI/clustering detects community structure across all connected networks.
166
167## Phased Implementation
168
169| Phase | What | Effort | Depends On |
170|-------|------|--------|-----------|
171| 0 | `bridge-atproto` — Keet rooms ↔ AT Proto posts, bridge-envelope + bridge-vault | 1-2 weeks | Nothing (same pattern as existing bridges) |
172| 1 | AT Proto DID linking in identity graph | 1 week | Phase 6 of main roadmap |
173| 2 | hyper-feed ingests AT Proto posts directly from PDSes | 2-3 weeks | Phase 4 of main roadmap |
174| 3 | Publish hyper-social Lexicons — wiki, board, feed become AT Proto-native | 2 weeks | Lexicon publishing on-protocol |
175| 4 | Hyperbee-backed PDS — AT Proto repos in Hypercore, Hyperswarm replication, HTTP gateway | 1 month | Phase 2-3 |
176
177Phase 0 follows the exact pattern of the five existing bridges. Each subsequent phase deepens integration while staying backward-compatible with the evolutionary roadmap.
178
179## References
180
181- [AT Protocol Self-Hosting Guide](https://atproto.com/guides/self-hosting)
182- [Relay Updates for Sync v1.1](https://atproto.com/blog/relay-updates-sync-v1-1)
183- [A Full-Network Relay for $34/month](https://whtwnd.com/bnewbold.net/3lo7a2a4qxg2l)
184- [Roomy Deep Dive](https://blog.muni.town/roomy-deep-dive/) — AT Proto + Automerge P2P hybrid
185- [Microcosm / Constellation](https://www.microcosm.blue/) — Raspberry Pi-scale backlink index
186- [Red Dwarf](https://reddwarf.app) — AppView-less Bluesky client
187- [Tangled](https://tangled.org) — Social Git forge on AT Proto
188- [Tranquil PDS](https://buttercup.wizardry.systems/) — Rust PDS with advanced auth
189- [Blacksky rsky](https://github.com/blacksky-algorithms/rsky) — Full Rust AT Proto stack
190- [IndieSky Working Group](https://atproto.wiki/en/working-groups/indiesky)
191- [2025 Protocol Roadmap](https://docs.bsky.app/blog/2025-protocol-roadmap-spring)
192- [Protocol Check-in Fall 2025](https://atproto.com/blog/protocol-check-in-fall-2025)
docs/COMMUNITY-MODERATION.md +700 -0
……@@ -0,0 +1,700 @@
1# Community Moderation and Governance
2
3How named communities moderate themselves, scale governance, and maintain the boundary between room sovereignty and community sovereignty. Companion to [COMMUNITY-EMERGENCE.md](COMMUNITY-EMERGENCE.md) (how communities form) and [ROOMS-ARE-THE-COMPUTER.md](ROOMS-ARE-THE-COMPUTER.md) (the implementation architecture).
4
5## the problem
6
7local MI clustering gives each user a personal view of their social graph. no moderation needed — it's your own folder organization. but the moment a human names a cluster and it becomes a shared community, three questions appear:
8
9- who moderates the community?
10- what exactly gets moderated?
11- how does governance scale beyond the founding group?
12
13these questions are entangled. the answer to each constrains the others.
14
15## who moderates
16
17### the namer seeds the context
18
19- when someone nucleates a cluster ("this is the testing community"), they carry implicit context about what "testing" means
20- they become initial admin not by authority but because they demonstrated enough understanding to name the pattern
21- this mirrors blacksky: fraser detected the statistical cluster from follow graphs, then named it. the naming act was political, not algorithmic
22
23### context transfers through people, not rules
24
25- the namer invites moderators who share context
26- a "rust p2p" community's mods know what's on-topic without a written policy. a "berlin meetup" community's mods understand local norms
27- you can't export one community's mod team to another — the context doesn't serialize
28- blacksky's formulation: "context-aware moderators with shared understanding of anti-blackness, misogynoir, internalized racism" — things that keyword filters will never catch
29- moderation is a section of the community sheaf — locally consistent within the community, non-transferable across communities
30
31### keet-core roles map directly
32
33the community-room uses existing keet-core roles. no new permission system:
34
35| room role | community meaning |
36|-----------|------------------|
37| admin | community founders — curate room listings, set norms, invite mods |
38| moderator | context-carriers — curate listings, flag rooms, can't change governance structure |
39| member | participants — browse listings, propose rooms for inclusion |
40| read-only | observers — see the community's room list, can't interact |
41
42### the community-room's chat IS the meta-discussion space
43
44- "should we add this room?"
45- "this room's content doesn't fit our norms"
46- "let's discuss our moderation approach"
47- governance happens in conversation, not in a separate governance tool (at least initially)
48
49## what gets moderated
50
51### listings, not content
52
53the critical boundary: a community moderator can remove a room from the community listing. they CANNOT moderate inside that room. two separate scopes, two separate sheaf sections.
54
55```
56room admin: sees room data (messages, members, content)
57community admin: sees community data (which rooms listed, their metadata)
58nobody: sees the full graph
59```
60
61### community admin powers
62
63**can do:**
64- add/remove `_hs:room-listing` messages in the community-room
65- set community metadata (topics, description, norms statement)
66- invite/ban community members (who can propose rooms)
67- pin governance decisions as messages
68
69**cannot do:**
70- read messages in listed rooms (unless they're also a member of that room independently)
71- see membership of listed rooms
72- moderate content inside listed rooms
73- track which community members joined which listed rooms
74
75### the accountability gap is intentional
76
77if a listed room has toxic content, the community admin's only lever is delisting. they can't reach inside and fix it. the room admin must fix it or the room gets delisted.
78
79this prevents community governance from becoming surveillance. the room admin is a bartender — they see what happens in their bar, they don't follow you home, they don't know what other bars you go to. the community admin is a neighborhood association — they decide which bars are listed in the directory, they don't enter the bars.
80
81### rooms in multiple communities
82
83a room listed in both "rust dev" and "berlin meetup" has two community-level governance contexts:
84- if "rust dev" delists it, "berlin meetup" still lists it
85- the room itself is unchanged
86- no single community has authority over a room
87- this is federation, not hierarchy
88
89### moderation signals, not enforcement
90
91instead of enforcement transfer between communities, signal:
92
93```
94community A's moderator flags a room → signal published as _hs:mod-signal
95community B subscribes to A's signals → sees the signal
96community B decides independently → keep listing, add warning, or delist
97```
98
99- moderation reputation becomes emergent — communities that produce good signals get subscribed to, bad signals get ignored
100- no central moderator — a network of moderation services that communities opt into
101- same model as blacksky's labeler: produces labels, each room/user independently decides how to respond (off / warn / hide per label type)
102
103### the _hs message types for moderation signals
104
105```javascript
106// a moderation signal (posted in a moderation-room or community-room)
107{"_hs":"mod-signal",
108 "target":"room:abc123",
109 "signal":"off-topic",
110 "reason":"crypto scam room, not related to testing",
111 "signalBy":"member:moderator0",
112 "signalAt":1708402400000}
113
114// subscribing to another community's signals = joining their moderation room
115// your client reads their _hs:mod-signal messages and surfaces them locally
116// you decide what to do — the signal is information, not enforcement
117```
118
119### what transfers across community boundaries
120
121not enforcement. signal.
122
123```
124not: "room X is banned" (enforcement transfer)
125but: "room X was flagged for spam by the rust-dev community" (signal transfer)
126
127your community applies its OWN context to the signal.
128```
129
130this is critical because moderation context doesn't serialize. what's spam in a dev community might be on-topic in a crypto community. what's off-topic in one context is the main event in another.
131
132## how governance scales
133
134### why voting breaks
135
136majority voting produces winners and losers. the minority feels unheard. over time, losers disengage or split. this is why every discord server eventually fractures along governance lines.
137
13852% vs 48% on a contentious issue doesn't resolve the tension — it suppresses the 48%. the 48% either comply resentfully, leave, or fork. none of these outcomes preserve community health.
139
140### consensus-finding instead
141
142blacksky uses polis — a tool where:
143
1441. members submit **statements** (not votes on predefined options)
1452. members **agree/disagree/pass** on each other's statements
1463. machine learning finds **clusters of agreement** — groups of people who tend to agree on similar things
1474. output is a **consensus map**: which statements have broad agreement, which are divisive, where the fault lines are
148
149the output isn't "option A won with 52%." it's:
150- "here's what almost everyone agrees on" (consensus zone)
151- "here are two groups that disagree on X" (fault lines)
152- "here's a statement that bridges the groups" (potential compromise)
153
154minorities aren't outvoted — they're visible as a cluster with their own position. the governance tool surfaces the topology of opinion rather than flattening it into a binary.
155
156### three phases of governance tooling
157
158governance can't be pre-installed. it must emerge in order:
159
160```
161statistical cluster → named community → identity forms →
162norms emerge → norms formalize → governance infrastructure
163```
164
165you can't impose governance on a community that hasn't formed identity. so the tooling phases match the community lifecycle:
166
167**phase 1: chat-based deliberation (small communities)**
168
169the community-room's chat IS the governance space. admins read the conversation, use context-judgment. this works for communities under ~50 active members — which is 90% of communities.
170
171```
172alice: "should we add the new crypto-testing room?"
173bob: "looks relevant, active overlap with our members"
174carol: "idk, last crypto room we added was full of scams"
175alice: "good point, let's wait and see if it stays legit"
176[admin decision: hold off for now]
177```
178
179no special tooling. the conversation IS the deliberation. the admin's decision is informed by the discussion but ultimately their call — benevolent dictator model, same as early blacksky.
180
181**phase 2: structured proposals (growing communities)**
182
183when chat-based deliberation gets noisy (too many voices, hard to track positions), structured `_hs:governance` messages:
184
185```javascript
186// a proposal
187{"_hs":"governance",
188 "type":"proposal",
189 "text":"add the crypto-testing room to our community",
190 "proposedBy":"member:alice0",
191 "proposedAt":1708402400000}
192
193// positions (not votes — positions with reasons)
194{"_hs":"governance",
195 "type":"position",
196 "proposalId":"msg:abc123",
197 "stance":"agree",
198 "reason":"good fit, 60% member overlap with our existing rooms",
199 "by":"member:bob0"}
200
201{"_hs":"governance",
202 "type":"position",
203 "proposalId":"msg:abc123",
204 "stance":"disagree",
205 "reason":"too many scam rooms in crypto space, quality risk",
206 "by":"member:carol0"}
207
208// resolution (admin decision after reading positions)
209{"_hs":"governance",
210 "type":"resolution",
211 "proposalId":"msg:abc123",
212 "decision":"deferred",
213 "reason":"split opinion — revisit in 2 weeks after observing room quality",
214 "resolvedBy":"member:admin0"}
215```
216
217the client renders these as structured cards — proposal at top, positions as a thread, resolution pinned. clearer than scrolling through chat. still admin-decided, but the reasoning is visible and auditable.
218
219**phase 3: consensus-finding (large communities)**
220
221polis-style dimensionality reduction across positions. the system collects agree/disagree/pass on statements, finds opinion clusters, surfaces consensus zones and fault lines.
222
223this is aspirational. most communities never reach the scale where this is needed. when they do, they've already built enough governance muscle through phases 1 and 2 to know what they need.
224
225### the sequence cannot be shortcut
226
227from COMMUNITY-EMERGENCE.md:
228
229> you can't impose governance on a community that hasn't formed identity. you can't form identity around a cluster that hasn't been named. you can't name a cluster that doesn't exist in the data. the pipeline must run in order.
230
231building phase 3 tooling for a phase 1 community is wasted effort. building phase 1 tooling for a pre-community (unnamed MI cluster) is premature. the system should detect which phase a community is in and offer appropriate tools.
232
233in practice: start with chat. add structured proposals when someone asks for them. add consensus-finding when structured proposals get overwhelmed. each upgrade is a `_hs` message type — no protocol changes, no new infrastructure.
234
235## the recursive loop
236
237governance modifies the graph it governs:
238
239```
240social graph → MI detection → communities → governance
241 ↑ |
242 └──── enforcement modifies graph ────────┘
243```
244
245banning a room from a community changes the listing, which changes what members see, which might change which rooms they join, which changes the MI clusters, which might dissolve or reshape the community.
246
247this is a fixed-point problem. the language pipeline (from SOCIAL-GRAPH-NAVIGATION.md) has the same structure: grammar extracted from corpus determines how new sentences are parsed, which feeds back into the corpus.
248
249### instability is health
250
251a community in stable equilibrium is dead or authoritarian. a living community's norms shift, people push back, deliberation happens, norms adjust. the social grammar is never final. the parse is always provisional.
252
253the MI clustering will detect when a community's internal structure no longer matches its name — rooms drifting apart, new sub-clusters forming, membership overlap declining. that's a signal to the humans that renegotiation is needed.
254
255### expect communities to split, merge, dissolve, reform
256
257- **split**: a sub-cluster within "testing" becomes distinct enough to warrant its own community ("integration-testing" vs "unit-testing")
258- **merge**: two communities with 80%+ room overlap realize they're the same community with two names
259- **dissolve**: a community's rooms drift apart until the MI drops below threshold — the name no longer describes reality
260- **reform**: dissolved community members reconverge around a new pattern, nucleate again with a different name
261
262the system surfaces these dynamics. the humans decide what to do.
263
264## the three sovereignties
265
266every governance decision exists in tension between three sovereignties:
267
268```
269protocol sovereignty keet/hypercore determines what's possible
270community sovereignty the named community determines what's acceptable
271individual sovereignty each person determines what's visible
272```
273
274### where they conflict
275
276**at the room boundary (protocol vs community):**
277a community wants to delist a room, but the room admin disagrees. protocol sovereignty says: the room exists independently of any community. community sovereignty says: we decide what's in our directory. resolution: the community can delist, but can't destroy the room. the room continues to exist, just not in that community's listing.
278
279**at the identity layer (community vs individual):**
280the community wants consistent identity for accountability ("same person across rooms"). the individual wants context-dependent presentation ("different persona in different spaces"). resolution: identity linking is opt-in (from ROOMS-ARE-THE-COMPUTER.md §7). the community can require linked identity for membership, but can't force it on non-members.
281
282**at the governance layer (all three):**
283a community's deliberation produces a norm: "no X-type content." protocol provides the enforcement mechanism (room roles, listing management). community decides the norm. individuals decide whether to stay in the community. all three sovereignties are exercised, none is overridden.
284
285### the tension is the feature
286
287from COMMUNITY-EMERGENCE.md:
288
289> non-trivial cohomology = real social tensions that can't be flattened. don't resolve them. surface them. let communities negotiate.
290
291a system that resolves all three sovereignties into a single hierarchy is authoritarian by design. the productive tension between protocol, community, and individual is what keeps the system healthy.
292
293## the observation is an intervention
294
295detecting a community changes the community. naming "blacksky" changed how people self-identified and interacted. showing someone their social graph changes their social graph.
296
297the graph navigation system is not a neutral observation tool. it's a social intervention. the choice of what to surface, what to highlight, what to cluster — these are design decisions that shape the social space.
298
299### design consequences
300
301- be explicit about what the tool shows and why
302- don't pretend algorithmic neutrality
303- surface the choices: "these rooms cluster because they share 70% of members" not just "group 1"
304- let users adjust: threshold sliders, feature weights, view modes
305- the view modes (topics, activity, people, category) are explicitly different lenses, not claims about ground truth
306
307## connection to existing implementation
308
309### what exists today (keet-daemon + godot-keet)
310
311- **local MI clustering** in `local-graph.js` — computes clusters from room membership, topics, category, activity, size
312- **view modes** in `group_bar.gd` — groups (manual), people (size quantiles), topics, category, activity
313- **graph visualization** in `graph_canvas.gd` — mindmap layout of clusters and rooms
314- **room metadata** via `_hs:room-meta` pinned messages — topics, category, type
315- **tag normalization** — `_normalizeTag()` strips non-alphanumeric edges, lowercases, so `_testing` and `Testing` cluster together
316
317### what "groups" view becomes
318
319today: manually created registry rooms (autobase-based, being deprecated).
320tomorrow: named communities — community-rooms with `_hs:room-listing` messages, room roles for governance, chat for meta-discussion.
321
322the transition from "registry autobase" to "community-room" is the same transition described in ROOMS-ARE-THE-COMPUTER.md §Step 4. the community-room IS the registry room, with governance naturally provided by keet-core's role system.
323
324### the nucleation prompt
325
326the bridge between local MI clustering (bottom-up) and named communities (top-down):
327
328```
3291. local MI detects a high-overlap cluster
3302. cluster has no matching named community
3313. UI surfaces: "5 rooms with 80% member overlap — name this group?"
3324. user names it → community-room created
3335. user becomes admin → invites mods → governance begins
334```
335
336this is layer 2 → layer 4 from AUTO-DISCOVERY.md. the nucleation prompt is the midwife — it surfaces the opportunity, the human decides whether and how to nucleate.
337
338### the moderation service as a room
339
340from ROOMS-ARE-THE-COMPUTER.md §8:
341
342a moderation service is a room whose messages are labels. subscribing = joining the room. labels are `_hs:mod-label` or `_hs:mod-signal` messages. the client reads labels and applies them according to user preference (off / warn / hide).
343
344multiple moderation services can overlap. users compose them. no central authority. moderation reputation is emergent — good labelers get subscribers, bad ones don't.
345
346## the view frustum: composable dimension stacking
347
348### the problem with flat views
349
350the current view modes are mutually exclusive — you pick activity OR people OR category. each gives a single-dimension clustering. but a single dimension is rarely enough to find what you're looking for:
351
352- activity alone splits rooms by message count, but "high activity" contains both dev rooms and social rooms
353- people alone splits by team size, but "small team" contains testing rooms and personal projects
354- category alone splits by topic, but "development" contains both active and dead rooms
355
356you need to stack dimensions: "show me active rooms, then within active, split by category." each added dimension narrows the view further.
357
358### the frustum model
359
360a frustum is a truncated cone — wide at one end, narrow at the other. each dimension is a cut that narrows the view:
361
362```
363all rooms (30)
364 └─ activity (primary): 3 clusters
365 ├─ high (12 rooms)
366 │ └─ category (secondary): 2 sub-clusters
367 │ ├─ development (7 rooms)
368 │ └─ testing (5 rooms)
369 ├─ medium (10 rooms)
370 │ └─ category (secondary): 3 sub-clusters
371 │ ├─ social (4 rooms)
372 │ ├─ development (3 rooms)
373 │ └─ infrastructure (3 rooms)
374 └─ low (8 rooms)
375 └─ category (secondary): 1 cluster
376 └─ misc (8 rooms)
377```
378
379the ordering matters. "activity → category" gives different results than "category → activity." the user controls the dimension stack — they decide what's primary, what refines.
380
381### how it differs from combined features
382
383| approach | what it does |
384|----------|-------------|
385| combined MI | `MI = 0.6 * activity_MI + 0.4 * category_MI` → flat clusters, dimensions blended |
386| frustum | cluster by activity first, THEN within each cluster, sub-cluster by category → tree |
387
388combined MI loses the hierarchy — you can't tell whether a cluster formed because of activity similarity or category similarity. the frustum preserves each dimension as a distinct level.
389
390### recursive sub-clustering algorithm
391
392```
393frustum(rooms, [dim1, dim2, dim3, ...], thresholds):
394 if no dimensions left OR rooms.length < minSize:
395 return leaf node {rooms}
396
397 clusters = MI_cluster(rooms, dim1, thresholds[0])
398
399 for each cluster in clusters:
400 cluster.children = frustum(cluster.rooms, [dim2, dim3, ...], thresholds[1:])
401
402 return clusters
403```
404
405each level:
4061. extract features for the current dimension (activity, people, category, topics, etc.)
4072. compute MI between rooms using only that dimension's features
4083. cluster by MST + threshold cut
4094. recurse into each cluster with the remaining dimensions
410
411the recursion stops when:
412- no more dimensions in the stack
413- a cluster is too small to sub-divide (< minSize rooms)
414- the sub-clustering produces no meaningful split (all rooms in one sub-cluster)
415
416### the RPC interface
417
418```javascript
419// current: single algo with flat features array
420graph.compute({
421 algo: {
422 name: "activity",
423 features: [{type: "activity", weight: 1.0}],
424 threshold: 0.1,
425 minSize: 2
426 }
427})
428
429// frustum: ordered dimension stack
430graph.compute({
431 algo: {
432 name: "frustum",
433 frustum: [
434 {type: "activity", threshold: 0.1},
435 {type: "category", threshold: 0.1},
436 {type: "people", threshold: 0.1}
437 ],
438 minSize: 2
439 }
440})
441```
442
443the `frustum` array replaces the `features` array. order matters — first element is primary dimension. each dimension can have its own threshold (coarser primary cuts, finer secondary cuts).
444
445### the response: tree, not flat list
446
447```javascript
448// current: flat cluster array
449{
450 clusters: [
451 {id: 0, rooms: ["a", "b", "c"], label: "testing"},
452 {id: 1, rooms: ["d", "e"], label: "development"}
453 ]
454}
455
456// frustum: nested tree
457{
458 clusters: [
459 {
460 id: 0,
461 dimension: "activity",
462 label: "5-12msgs",
463 rooms: ["a", "b", "c", "d", "e"],
464 children: [
465 {
466 id: "0.0",
467 dimension: "category",
468 label: "testing",
469 rooms: ["a", "b", "c"],
470 children: []
471 },
472 {
473 id: "0.1",
474 dimension: "category",
475 label: "development",
476 rooms: ["d", "e"],
477 children: []
478 }
479 ]
480 },
481 {
482 id: 1,
483 dimension: "activity",
484 label: "50-100msgs",
485 rooms: ["f", "g", "h"],
486 children: [
487 {
488 id: "1.0",
489 dimension: "category",
490 label: "social",
491 rooms: ["f", "g", "h"],
492 children: []
493 }
494 ]
495 }
496 ]
497}
498```
499
500leaf clusters (children = []) are the actual groups shown in the UI. parent clusters provide the hierarchy for navigation and labeling.
501
502### the UI: dimension chip stack
503
504instead of a single dropdown, the group bar header gets a stack of dimension chips:
505
506```
507[ Activity ▼ ] [ + Category ▼ ] [ + ]
508```
509
510- each chip is an OptionButton with the available dimensions
511- the `[+]` button adds another dimension to the stack
512- removing a chip (click X) collapses the frustum at that level
513- reordering chips (drag) changes the hierarchy
514- the group bar shows the tree: primary clusters as top-level icons, with expandable sub-groups
515
516### group bar rendering
517
518```
519┌─────────┐
520│ ⌂ Home │ ← all rooms
521├─────────┤
522│ 5-12msg │ ← primary: activity cluster (collapsed)
523│ testing│ ← secondary: category sub-cluster
524│ dev │ ← secondary: category sub-cluster
525├─────────┤
526│ 50-100 │ ← primary: activity cluster (collapsed)
527│ social │ ← secondary: category sub-cluster
528├─────────┤
529│ empty │ ← primary: activity cluster (no sub-clusters)
530└─────────┘
531```
532
533clicking a primary cluster icon selects all rooms in it (including all sub-clusters). clicking a secondary icon selects only that sub-cluster's rooms. the indentation communicates hierarchy.
534
535### backwards compatibility
536
537- single-dimension algos still work exactly as today (frustum with 1 element = flat clustering)
538- the `features` array in algo config continues to work for the existing combined-MI mode
539- `frustum` array is a new alternative — if present, it takes precedence over `features`
540- the response includes a `tree` flag so the client knows whether to render flat or hierarchical
541
542### implementation sketch: local-graph.js
543
544```javascript
545async _runFrustum (roomIds, algo, dmSharedRooms) {
546 const dims = algo.frustum
547 const minSize = algo.minSize || 2
548
549 const tree = await this._frustumRecurse(roomIds, dims, 0, minSize)
550 await this._labelFrustumTree(tree)
551
552 // flatten leaf clusters for backwards-compat edges
553 const leaves = this._collectLeaves(tree)
554 const suggestions = this._buildSuggestions(
555 await this._extractFeatures(roomIds, [{type: dims[0].type, weight: 1.0}]),
556 dmSharedRooms
557 )
558
559 return { clusters: tree, edges: [], suggestions, algo: algo.name, tree: true }
560}
561
562async _frustumRecurse (roomIds, dims, depth, minSize) {
563 if (dims.length === 0 || roomIds.length < minSize) {
564 return [] // leaf — rooms returned as-is to parent
565 }
566
567 const dim = dims[0]
568 const remaining = dims.slice(1)
569
570 // cluster by this dimension
571 const features = await this._extractFeatures(roomIds, [{type: dim.type, weight: 1.0}])
572 const index = this._buildInvertedIndex(features)
573 const { pairs, totalPairs } = this._computePairCounts(index)
574 const edges = this._computeMI(pairs, features, totalPairs)
575 const clusters = this._extractClusters(edges, roomIds, dim.threshold, minSize)
576
577 // collect unclustered rooms
578 const clustered = new Set()
579 for (const c of clusters) for (const r of c.rooms) clustered.add(r)
580 const unclustered = roomIds.filter(r => !clustered.has(r))
581
582 const result = []
583 for (let i = 0; i < clusters.length; i++) {
584 const c = clusters[i]
585 const children = await this._frustumRecurse(c.rooms, remaining, depth + 1, minSize)
586 result.push({
587 id: c.id,
588 dimension: dim.type,
589 label: '', // filled by _labelFrustumTree
590 rooms: c.rooms,
591 children
592 })
593 }
594
595 // unclustered rooms become a catch-all node (no sub-clustering)
596 if (unclustered.length > 0) {
597 result.push({
598 id: 'other',
599 dimension: dim.type,
600 label: 'other',
601 rooms: unclustered,
602 children: []
603 })
604 }
605
606 return result
607}
608```
609
610### implementation sketch: group_bar.gd
611
612```gdscript
613# dimension stack replaces single VIEW_MODES selector
614var _frustum_stack: Array[String] = [] # ["activity", "category"]
615
616const DIMENSIONS: Array[Dictionary] = [
617 {"type": "activity", "label": "Activity", "threshold": 0.1},
618 {"type": "category", "label": "Category", "threshold": 0.1},
619 {"type": "topics", "label": "Topics", "threshold": 0.3},
620 {"type": "size", "label": "People", "threshold": 0.1},
621 {"type": "creator", "label": "Creator", "threshold": 0.1},
622]
623
624func _build_frustum_algo() -> Dictionary:
625 if _frustum_stack.is_empty():
626 return {} # groups mode — no clustering
627 var frustum: Array = []
628 for dim_type: String in _frustum_stack:
629 for d: Dictionary in DIMENSIONS:
630 if d["type"] == dim_type:
631 frustum.append({"type": d["type"], "threshold": d["threshold"]})
632 break
633 return {"name": "frustum", "frustum": frustum, "minSize": 2}
634
635func _render_tree(clusters: Array, indent: int = 0) -> void:
636 for cluster: Dictionary in clusters:
637 var icon := _create_group_icon(cluster["id"], cluster["label"],
638 cluster["dimension"], "", false)
639 icon.custom_minimum_size.x = 60 - (indent * 4) # visual indent
640 group_icons.add_child(icon)
641 # recurse into children
642 if cluster.has("children"):
643 _render_tree(cluster["children"], indent + 1)
644```
645
646## design principles (from this document)
647
648```
6491. CONTEXT OVER RULES
650 moderators share understanding, not policy documents.
651 moderation context doesn't serialize across communities.
652
6532. LISTINGS, NOT CONTENT
654 community governance controls the directory.
655 room governance controls the room. never conflate them.
656
6573. SIGNAL, NOT ENFORCEMENT
658 moderation crosses community boundaries as information.
659 the receiving community decides what to do with the signal.
660
6614. CONSENSUS, NOT MAJORITY
662 surface the topology of opinion rather than flattening
663 it into a binary. minorities are visible, not outvoted.
664
6655. PHASE-APPROPRIATE TOOLING
666 chat for small communities. structured proposals for growing
667 ones. consensus-finding for large ones. don't pre-install.
668
6696. THE ACCOUNTABILITY GAP IS INTENTIONAL
670 community admins can't reach inside rooms. room admins
671 can't force listing in communities. the gap prevents surveillance.
672
6737. EXPECT LIFECYCLE
674 communities split, merge, dissolve, reform. the system
675 surfaces these dynamics. the humans decide what to do.
676
6778. THREE SOVEREIGNTIES IN TENSION
678 protocol, community, individual. the tension is the feature.
679 resolving it into hierarchy is authoritarian by design.
680```
681
682## connection to other documents
683
684- [COMMUNITY-EMERGENCE.md](COMMUNITY-EMERGENCE.md) — how communities form: phase transitions, nucleation, blacksky case study
685- [SOCIAL-GRAPH-NAVIGATION.md](SOCIAL-GRAPH-NAVIGATION.md) — the math: MI, MST, sheaf theory, connector profiles
686- [ROOMS-ARE-THE-COMPUTER.md](ROOMS-ARE-THE-COMPUTER.md) — the implementation: rooms as only data structure, `_hs` conventions, migration path
687- [AUTO-DISCOVERY.md](AUTO-DISCOVERY.md) — the five discovery layers: DHT → room-carried metadata → MI clustering → gossip → collaborative auto-registry
688
689## appendix: frustum dimension types
690
691| dimension | what it measures | typical threshold | bucket method |
692|-----------|-----------------|-------------------|---------------|
693| activity | recent message count | 0.1 | quantile (6 buckets, descriptive labels like `act:5-12msgs`) |
694| category | MetadataBase category tag | 0.1 | exact match (normalized: `_testing` → `testing`) |
695| topics | MetadataBase topics array | 0.3 | set overlap (room can have multiple topics) |
696| size / people | member count | 0.1 | quantile (6 buckets, labels like `sz:3-5ppl`) |
697| creator | room admin identity | 0.1 | exact match on admin memberId |
698| members | membership overlap | 0.5 | set overlap (excludes local user) |
699
700dimensions with quantile bucketing (activity, size) produce equal-sized groups from the data distribution. dimensions with exact/set matching (category, topics, members) produce groups from shared metadata. the frustum can stack any combination.
hyper-social-web/index.js +32 -0
……@@ -0,0 +1,32 @@
1#!/usr/bin/env bare
2
3/**
4 * hyper-social-web — POSSE Social Network Client
5 *
6 * Unified timeline and cross-posting for Mastodon, Bluesky, and Instagram.
7 * Publish once, syndicate everywhere.
8 */
9
10await import('bare-node-runtime/global')
11
12import { createServer } from './lib/server.js'
13
14const args = process.argv.slice(2)
15
16const cmd = args[0] || 'serve'
17
18if (cmd === 'serve') {
19 const portIdx = args.indexOf('--port')
20 const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 5050
21
22 const prefixIdx = args.indexOf('--prefix')
23 const prefix = prefixIdx !== -1 ? args[prefixIdx + 1] : ''
24
25 const storageIdx = args.indexOf('--storage')
26 const storage = storageIdx !== -1 ? args[storageIdx + 1] : null
27
28 const server = await createServer({ port, prefix, storage })
29 console.log(`[hyper-social-web] v0.1.0 listening on http://localhost:${port}${prefix}/`)
30} else {
31 console.log('Usage: bare index.js serve [--port 5050] [--prefix /social] [--storage ./data]')
32}
hyper-social-web/lib/clients/atproto-client.js +277 -0
……@@ -0,0 +1,277 @@
1import https from 'bare-https'
2
3/**
4 * Minimal AT Protocol client using raw XRPC HTTP calls.
5 * No SDK dependency — just fetch + JSON + JWT.
6 */
7class AtprotoClient {
8 constructor (opts = {}) {
9 this.service = opts.service || 'https://bsky.social'
10 this.identifier = opts.identifier // handle or DID
11 this.password = opts.password // app password
12
13 // Session state
14 this.accessJwt = null
15 this.refreshJwt = null
16 this.did = null
17 this.handle = null
18
19 // Parse hostname from service URL
20 const url = new URL(this.service)
21 this.hostname = url.hostname
22 this.basePath = url.pathname === '/' ? '' : url.pathname
23 }
24
25 _request (method, nsid, params, body) {
26 return new Promise((resolve, reject) => {
27 let path = `${this.basePath}/xrpc/${nsid}`
28
29 // Query params for GET
30 if (params && method === 'GET') {
31 const qs = new URLSearchParams()
32 for (const [k, v] of Object.entries(params)) {
33 if (v !== undefined && v !== null) qs.set(k, String(v))
34 }
35 const str = qs.toString()
36 if (str) path += '?' + str
37 }
38
39 const opts = {
40 hostname: this.hostname,
41 port: 443,
42 path,
43 method,
44 headers: { Accept: 'application/json' }
45 }
46
47 if (this.accessJwt) {
48 opts.headers.Authorization = `Bearer ${this.accessJwt}`
49 }
50
51 let payload = ''
52 if (body && method === 'POST') {
53 payload = JSON.stringify(body)
54 opts.headers['Content-Type'] = 'application/json'
55 opts.headers['Content-Length'] = Buffer.byteLength(payload)
56 }
57
58 const req = https.request(opts, (res) => {
59 let data = ''
60 res.on('data', (chunk) => { data += chunk.toString() })
61 res.on('end', () => {
62 if (res.statusCode >= 400) {
63 // Check if token expired
64 if (res.statusCode === 401 && this.refreshJwt) {
65 return this._refreshSession()
66 .then(() => this._request(method, nsid, params, body))
67 .then(resolve)
68 .catch(reject)
69 }
70 return reject(new Error(`XRPC ${res.statusCode}: ${data.slice(0, 300)}`))
71 }
72 try { resolve(JSON.parse(data)) } catch { resolve(data) }
73 })
74 })
75
76 req.on('error', reject)
77 req.setTimeout(15000, () => req.destroy(new Error('Request timeout')))
78 if (payload) req.write(payload)
79 req.end()
80 })
81 }
82
83 // --- Session management ---
84
85 async login () {
86 const res = await this._requestNoAuth('POST', 'com.atproto.server.createSession', null, {
87 identifier: this.identifier,
88 password: this.password
89 })
90 this.accessJwt = res.accessJwt
91 this.refreshJwt = res.refreshJwt
92 this.did = res.did
93 this.handle = res.handle
94 return res
95 }
96
97 async _refreshSession () {
98 const oldRefresh = this.refreshJwt
99 this.accessJwt = null
100
101 const res = await this._requestWithToken('POST', 'com.atproto.server.refreshSession', null, null, oldRefresh)
102 this.accessJwt = res.accessJwt
103 this.refreshJwt = res.refreshJwt
104 return res
105 }
106
107 _requestNoAuth (method, nsid, params, body) {
108 const saved = this.accessJwt
109 this.accessJwt = null
110 return this._request(method, nsid, params, body).finally(() => {
111 this.accessJwt = saved
112 })
113 }
114
115 _requestWithToken (method, nsid, params, body, token) {
116 const saved = this.accessJwt
117 this.accessJwt = token
118 return this._request(method, nsid, params, body).finally(() => {
119 this.accessJwt = saved
120 })
121 }
122
123 // --- Feed reading ---
124
125 async getTimeline (limit = 30, cursor) {
126 const res = await this._request('GET', 'app.bsky.feed.getTimeline', { limit, cursor })
127 return res.feed || []
128 }
129
130 async getAuthorFeed (actor, limit = 30, cursor) {
131 const res = await this._request('GET', 'app.bsky.feed.getAuthorFeed', {
132 actor: actor || this.did,
133 limit,
134 cursor
135 })
136 return res.feed || []
137 }
138
139 async getProfile (actor) {
140 return this._request('GET', 'app.bsky.actor.getProfile', {
141 actor: actor || this.did
142 })
143 }
144
145 // --- Blob upload ---
146
147 async uploadBlob (buffer, mimeType) {
148 return new Promise((resolve, reject) => {
149 const req = https.request({
150 hostname: this.hostname,
151 port: 443,
152 path: `${this.basePath}/xrpc/com.atproto.repo.uploadBlob`,
153 method: 'POST',
154 headers: {
155 Authorization: `Bearer ${this.accessJwt}`,
156 'Content-Type': mimeType,
157 'Content-Length': buffer.length
158 }
159 }, (res) => {
160 let data = ''
161 res.on('data', (chunk) => { data += chunk.toString() })
162 res.on('end', () => {
163 if (res.statusCode >= 400) {
164 return reject(new Error(`Upload ${res.statusCode}: ${data.slice(0, 300)}`))
165 }
166 try { resolve(JSON.parse(data)) } catch { reject(new Error('Invalid JSON')) }
167 })
168 })
169
170 req.on('error', reject)
171 req.setTimeout(60000, () => req.destroy(new Error('Upload timeout')))
172 req.write(buffer)
173 req.end()
174 })
175 }
176
177 // --- Posting ---
178
179 async post (text, opts = {}) {
180 const now = new Date().toISOString()
181
182 const record = {
183 $type: 'app.bsky.feed.post',
184 text,
185 createdAt: now
186 }
187
188 // Facets (mentions, links, hashtags) — auto-detect
189 const facets = detectFacets(text)
190 if (facets.length > 0) record.facets = facets
191
192 // Reply
193 if (opts.replyTo) {
194 record.reply = opts.replyTo
195 }
196
197 // Image embed
198 if (opts.images && opts.images.length > 0) {
199 record.embed = {
200 $type: 'app.bsky.embed.images',
201 images: opts.images.map(img => ({
202 alt: img.alt || '',
203 image: img.blob, // { $type: 'blob', ref: { $link: cid }, mimeType, size }
204 aspectRatio: img.width && img.height ? { width: img.width, height: img.height } : undefined
205 }))
206 }
207 }
208
209 return this._request('POST', 'com.atproto.repo.createRecord', null, {
210 repo: this.did,
211 collection: 'app.bsky.feed.post',
212 record
213 })
214 }
215
216 // --- Notifications ---
217
218 async getNotifications (limit = 20, cursor) {
219 const res = await this._request('GET', 'app.bsky.notification.listNotifications', { limit, cursor })
220 return res.notifications || []
221 }
222
223 // --- Search ---
224
225 async searchPosts (query, limit = 25) {
226 const res = await this._request('GET', 'app.bsky.feed.searchPosts', { q: query, limit })
227 return res.posts || []
228 }
229}
230
231/**
232 * Auto-detect facets (links, mentions, hashtags) in text.
233 * Returns AT Proto facet objects with byte offsets.
234 */
235function detectFacets (text) {
236 const facets = []
237 const encoder = new TextEncoder()
238 const bytes = encoder.encode(text)
239
240 // URLs
241 const urlRegex = /https?:\/\/[^\s<>)"']+/g
242 let match
243 while ((match = urlRegex.exec(text)) !== null) {
244 const before = encoder.encode(text.slice(0, match.index))
245 const target = encoder.encode(match[0])
246 facets.push({
247 index: { byteStart: before.length, byteEnd: before.length + target.length },
248 features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }]
249 })
250 }
251
252 // Mentions (@handle.domain)
253 const mentionRegex = /@([a-zA-Z0-9._-]+\.[a-zA-Z]{2,})/g
254 while ((match = mentionRegex.exec(text)) !== null) {
255 const before = encoder.encode(text.slice(0, match.index))
256 const target = encoder.encode(match[0])
257 facets.push({
258 index: { byteStart: before.length, byteEnd: before.length + target.length },
259 features: [{ $type: 'app.bsky.richtext.facet#mention', did: '' }] // DID resolved by AppView
260 })
261 }
262
263 // Hashtags (#tag)
264 const tagRegex = /#([a-zA-Z0-9_]+)/g
265 while ((match = tagRegex.exec(text)) !== null) {
266 const before = encoder.encode(text.slice(0, match.index))
267 const target = encoder.encode(match[0])
268 facets.push({
269 index: { byteStart: before.length, byteEnd: before.length + target.length },
270 features: [{ $type: 'app.bsky.richtext.facet#tag', tag: match[1] }]
271 })
272 }
273
274 return facets
275}
276
277export { AtprotoClient, detectFacets }
hyper-social-web/lib/clients/mastodon-client.js +123 -0
……@@ -0,0 +1,123 @@
1import https from 'bare-https'
2
3class MastodonClient {
4 constructor (opts = {}) {
5 this.instance = opts.instance
6 this.token = opts.token
7 }
8
9 _request (method, path, body) {
10 return new Promise((resolve, reject) => {
11 const opts = {
12 hostname: this.instance,
13 port: 443,
14 path,
15 method,
16 headers: {
17 Authorization: `Bearer ${this.token}`,
18 Accept: 'application/json'
19 }
20 }
21
22 let payload = ''
23 if (body) {
24 payload = JSON.stringify(body)
25 opts.headers['Content-Type'] = 'application/json'
26 opts.headers['Content-Length'] = Buffer.byteLength(payload)
27 }
28
29 const req = https.request(opts, (res) => {
30 let data = ''
31 res.on('data', (chunk) => { data += chunk.toString() })
32 res.on('end', () => {
33 if (res.statusCode >= 400) {
34 return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`))
35 }
36 try { resolve(JSON.parse(data)) } catch { reject(new Error('Invalid JSON')) }
37 })
38 })
39
40 req.on('error', reject)
41 req.setTimeout(15000, () => req.destroy(new Error('Request timeout')))
42 if (payload) req.write(payload)
43 req.end()
44 })
45 }
46
47 async postStatus (text, opts = {}) {
48 const body = { status: text }
49 if (opts.visibility) body.visibility = opts.visibility
50 if (opts.spoilerText) body.spoiler_text = opts.spoilerText
51 if (opts.inReplyTo) body.in_reply_to_id = opts.inReplyTo
52 if (opts.mediaIds && opts.mediaIds.length) body.media_ids = opts.mediaIds
53 return this._request('POST', '/api/v1/statuses', body)
54 }
55
56 // Upload media via multipart/form-data
57 async uploadMedia (buffer, mimeType, description) {
58 return new Promise((resolve, reject) => {
59 const boundary = '----HyperSocial' + Date.now().toString(36)
60 const parts = []
61
62 // File part
63 parts.push(`--${boundary}\r\n`)
64 parts.push(`Content-Disposition: form-data; name="file"; filename="media.${mimeType.split('/')[1] || 'jpg'}"\r\n`)
65 parts.push(`Content-Type: ${mimeType}\r\n\r\n`)
66 const header = Buffer.from(parts.join(''))
67
68 const footer = Buffer.from(`\r\n`)
69 let descPart = Buffer.alloc(0)
70 if (description) {
71 descPart = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="description"\r\n\r\n${description}\r\n`)
72 }
73 const end = Buffer.from(`--${boundary}--\r\n`)
74
75 const payload = Buffer.concat([header, buffer, footer, descPart, end])
76
77 const req = https.request({
78 hostname: this.instance,
79 port: 443,
80 path: '/api/v2/media',
81 method: 'POST',
82 headers: {
83 Authorization: `Bearer ${this.token}`,
84 Accept: 'application/json',
85 'Content-Type': `multipart/form-data; boundary=${boundary}`,
86 'Content-Length': payload.length
87 }
88 }, (res) => {
89 let data = ''
90 res.on('data', (chunk) => { data += chunk.toString() })
91 res.on('end', () => {
92 if (res.statusCode >= 400) {
93 return reject(new Error(`Upload ${res.statusCode}: ${data.slice(0, 200)}`))
94 }
95 try { resolve(JSON.parse(data)) } catch { reject(new Error('Invalid JSON')) }
96 })
97 })
98
99 req.on('error', reject)
100 req.setTimeout(60000, () => req.destroy(new Error('Upload timeout')))
101 req.write(payload)
102 req.end()
103 })
104 }
105
106 async homeTimeline (limit = 20, sinceId) {
107 let path = `/api/v1/timelines/home?limit=${limit}`
108 if (sinceId) path += `&since_id=${sinceId}`
109 return this._request('GET', path)
110 }
111
112 async notifications (limit = 15, sinceId) {
113 let path = `/api/v1/notifications?limit=${limit}`
114 if (sinceId) path += `&since_id=${sinceId}`
115 return this._request('GET', path)
116 }
117
118 async verifyCredentials () {
119 return this._request('GET', '/api/v1/accounts/verify_credentials')
120 }
121}
122
123export { MastodonClient }
hyper-social-web/lib/feed-store.js +84 -0
……@@ -0,0 +1,84 @@
1import { randomBytes } from 'bare-crypto'
2
3const SOURCES = ['mastodon', 'atproto', 'instagram', 'local']
4
5class FeedStore {
6 constructor (bee) {
7 this.bee = bee
8 }
9
10 _genId () {
11 return randomBytes(8).toString('hex')
12 }
13
14 _padTimestamp (ts) {
15 return String(ts).padStart(16, '0')
16 }
17
18 async add (opts = {}) {
19 const id = opts.id || this._genId()
20 const now = Date.now()
21
22 const item = {
23 id,
24 source: opts.source || 'local',
25 author: opts.author || 'unknown',
26 authorId: opts.authorId || opts.author || 'unknown',
27 text: opts.text || '',
28 timestamp: opts.timestamp || now,
29 type: opts.type || 'post',
30 tags: opts.tags || [],
31 url: opts.url || null,
32 mediaUrl: opts.mediaUrl || null,
33 mediaType: opts.mediaType || null,
34 metadata: opts.metadata || {},
35 syndicated: opts.syndicated || null
36 }
37
38 const batch = this.bee.batch()
39 await batch.put(`item/${id}`, item)
40 await batch.put(`timeline/${this._padTimestamp(item.timestamp)}/${id}`, { id })
41 await batch.put(`source/${item.source}/${this._padTimestamp(item.timestamp)}/${id}`, { id })
42 await batch.put(`author/${item.authorId}/${this._padTimestamp(item.timestamp)}/${id}`, { id })
43 await batch.flush()
44
45 return item
46 }
47
48 async get (id) {
49 const node = await this.bee.get(`item/${id}`)
50 return node ? node.value : null
51 }
52
53 async timeline (opts = {}) {
54 const { limit = 50, before, after, source } = opts
55 const items = []
56
57 const rangeOpts = {}
58 if (source) {
59 rangeOpts.gte = `source/${source}/`
60 rangeOpts.lte = `source/${source}/~`
61 if (before) rangeOpts.lt = `source/${source}/${this._padTimestamp(before)}/`
62 } else {
63 rangeOpts.gte = 'timeline/'
64 rangeOpts.lte = 'timeline/~'
65 if (before) rangeOpts.lt = `timeline/${this._padTimestamp(before)}/`
66 if (after) rangeOpts.gt = `timeline/${this._padTimestamp(after)}/~`
67 }
68
69 // Read in reverse (newest first)
70 rangeOpts.reverse = true
71
72 for await (const node of this.bee.createReadStream(rangeOpts)) {
73 if (!node.value) continue
74 const item = await this.get(node.value.id)
75 if (!item) continue
76 items.push(item)
77 if (items.length >= limit) break
78 }
79
80 return items
81 }
82}
83
84export { FeedStore, SOURCES }
hyper-social-web/lib/media.js +234 -0
……@@ -0,0 +1,234 @@
1import { spawn } from 'bare-subprocess'
2import fs from 'bare-fs'
3import path from 'bare-path'
4import os from 'bare-os'
5import { randomBytes } from 'bare-crypto'
6import FFmpeg from 'bare-ffmpeg'
7
8const TEMP_DIR = path.join(os.tmpdir(), 'hyper-social-media')
9try { fs.mkdirSync(TEMP_DIR, { recursive: true }) } catch {}
10
11/**
12 * Process an image for cross-platform POSSE publishing.
13 *
14 * - Resize to max 2048px on longest side
15 * - Compress to JPEG 80% quality
16 * - Strip EXIF/metadata (privacy)
17 * - Target: under 1MB (Bluesky limit)
18 * - Falls back to lower quality if still over 1MB
19 *
20 * @param {Buffer} input - Raw image data
21 * @param {string} filename - Original filename (for extension detection)
22 * @returns {{ buffer: Buffer, mimeType: string, width: number, height: number }}
23 */
24async function processImage (input, filename) {
25 const id = randomBytes(8).toString('hex')
26 const inputPath = path.join(TEMP_DIR, `${id}-input${extFor(filename)}`)
27 const outputPath = path.join(TEMP_DIR, `${id}-output.jpg`)
28
29 fs.writeFileSync(inputPath, input)
30
31 try {
32 // First pass: resize + compress to JPEG 80%
33 await ffmpeg([
34 '-i', inputPath,
35 '-vf', 'scale=2048:2048:force_original_aspect_ratio=decrease',
36 '-q:v', '4', // JPEG quality ~80%
37 '-map_metadata', '-1', // Strip all metadata
38 '-y', outputPath
39 ])
40
41 let output = fs.readFileSync(outputPath)
42
43 // If still over 1MB (Bluesky limit), compress harder
44 if (output.length > 1000000) {
45 await ffmpeg([
46 '-i', inputPath,
47 '-vf', 'scale=1600:1600:force_original_aspect_ratio=decrease',
48 '-q:v', '8', // Lower quality
49 '-map_metadata', '-1',
50 '-y', outputPath
51 ])
52 output = fs.readFileSync(outputPath)
53 }
54
55 // If STILL over 1MB, go aggressive
56 if (output.length > 1000000) {
57 await ffmpeg([
58 '-i', inputPath,
59 '-vf', 'scale=1200:1200:force_original_aspect_ratio=decrease',
60 '-q:v', '12',
61 '-map_metadata', '-1',
62 '-y', outputPath
63 ])
64 output = fs.readFileSync(outputPath)
65 }
66
67 const dimensions = await getImageDimensions(outputPath)
68
69 return {
70 buffer: output,
71 mimeType: 'image/jpeg',
72 width: dimensions.width,
73 height: dimensions.height
74 }
75 } finally {
76 cleanup(inputPath, outputPath)
77 }
78}
79
80/**
81 * Process a video for cross-platform POSSE publishing.
82 *
83 * - Re-encode to H.264 MP4
84 * - Cap duration at 60 seconds (Bluesky limit)
85 * - Reasonable bitrate for web delivery
86 * - Generate thumbnail
87 *
88 * @param {Buffer} input - Raw video data
89 * @param {string} filename - Original filename
90 * @returns {{ buffer: Buffer, mimeType: string, thumbnail: Buffer, duration: number }}
91 */
92async function processVideo (input, filename) {
93 const id = randomBytes(8).toString('hex')
94 const inputPath = path.join(TEMP_DIR, `${id}-input${extFor(filename)}`)
95 const outputPath = path.join(TEMP_DIR, `${id}-output.mp4`)
96 const thumbPath = path.join(TEMP_DIR, `${id}-thumb.jpg`)
97
98 fs.writeFileSync(inputPath, input)
99
100 try {
101 // Transcode: H.264, AAC, max 60s, reasonable quality
102 await ffmpeg([
103 '-i', inputPath,
104 '-t', '60', // Cap at 60 seconds
105 '-c:v', 'libx264',
106 '-preset', 'medium',
107 '-crf', '23', // Good quality/size balance
108 '-c:a', 'aac',
109 '-b:a', '128k',
110 '-movflags', '+faststart', // Web-optimized
111 '-vf', 'scale=1920:1920:force_original_aspect_ratio=decrease:force_divisible_by=2',
112 '-map_metadata', '-1',
113 '-y', outputPath
114 ])
115
116 // Generate thumbnail at 1 second
117 await ffmpeg([
118 '-i', inputPath,
119 '-ss', '1',
120 '-frames:v', '1',
121 '-vf', 'scale=800:800:force_original_aspect_ratio=decrease',
122 '-q:v', '4',
123 '-y', thumbPath
124 ])
125
126 const output = fs.readFileSync(outputPath)
127 const thumbnail = fs.readFileSync(thumbPath)
128 const duration = await getVideoDuration(outputPath)
129
130 return {
131 buffer: output,
132 mimeType: 'video/mp4',
133 thumbnail,
134 duration
135 }
136 } finally {
137 cleanup(inputPath, outputPath, thumbPath)
138 }
139}
140
141/**
142 * Detect if a file is an image or video based on extension/mime.
143 */
144function detectMediaType (filename, mimeType) {
145 const ext = (path.extname(filename) || '').toLowerCase()
146 if (mimeType) {
147 if (mimeType.startsWith('image/')) return 'image'
148 if (mimeType.startsWith('video/')) return 'video'
149 }
150 if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.avif'].includes(ext)) return 'image'
151 if (['.mp4', '.webm', '.mov', '.avi', '.mkv'].includes(ext)) return 'video'
152 return null
153}
154
155/**
156 * Process any media file — routes to image or video processor.
157 */
158async function processMedia (input, filename, mimeType) {
159 const type = detectMediaType(filename, mimeType)
160 if (type === 'image') return { type: 'image', ...(await processImage(input, filename)) }
161 if (type === 'video') return { type: 'video', ...(await processVideo(input, filename)) }
162 throw new Error('Unsupported media type: ' + filename)
163}
164
165// --- Internal helpers ---
166
167function ffmpeg (args) {
168 return new Promise((resolve, reject) => {
169 const proc = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', ...args])
170 let stderr = ''
171 proc.stderr.on('data', (d) => { stderr += d.toString() })
172 proc.on('exit', (code) => {
173 if (code !== 0) reject(new Error('ffmpeg failed: ' + stderr.slice(0, 500)))
174 else resolve()
175 })
176 proc.on('error', reject)
177 })
178}
179
180function getMediaInfo (filePath) {
181 try {
182 const io = new FFmpeg.IOContext(filePath, FFmpeg.constants.AVIO_FLAG_READ)
183 const ctx = new FFmpeg.InputFormatContext(io)
184 const info = { width: 0, height: 0, duration: 0 }
185
186 for (const stream of ctx.streams) {
187 const params = stream.codecpar
188 if (params.codecType === FFmpeg.constants.AVMEDIA_TYPE_VIDEO) {
189 info.width = params.width
190 info.height = params.height
191 }
192 }
193
194 // Duration in seconds (format context stores in microseconds or via stream timebase)
195 ctx.destroy()
196 return info
197 } catch {
198 return { width: 0, height: 0, duration: 0 }
199 }
200}
201
202function getImageDimensions (filePath) {
203 const info = getMediaInfo(filePath)
204 return { width: info.width, height: info.height }
205}
206
207function getVideoDuration (filePath) {
208 // bare-ffmpeg duration access may vary — fall back to ffprobe subprocess
209 return new Promise((resolve) => {
210 const proc = spawn('ffprobe', [
211 '-v', 'quiet', '-print_format', 'json', '-show_format', filePath
212 ])
213 let out = ''
214 proc.stdout.on('data', (d) => { out += d.toString() })
215 proc.on('exit', () => {
216 try {
217 resolve(Math.round(parseFloat(JSON.parse(out).format?.duration || '0')))
218 } catch { resolve(0) }
219 })
220 proc.on('error', () => resolve(0))
221 })
222}
223
224function extFor (filename) {
225 return path.extname(filename) || '.bin'
226}
227
228function cleanup (...files) {
229 for (const f of files) {
230 try { fs.unlinkSync(f) } catch {}
231 }
232}
233
234export { processImage, processVideo, processMedia, detectMediaType }
hyper-social-web/lib/posse.js +278 -0
……@@ -0,0 +1,278 @@
1/**
2 * POSSE Engine — Publish Own Site, Syndicate Elsewhere
3 *
4 * Compose once, publish to selected platforms.
5 * Stores all posts and ingested timeline items in FeedStore.
6 */
7
8class POSSEEngine {
9 constructor (feedStore) {
10 this.feedStore = feedStore
11 this.clients = new Map() // id -> { platform, client }
12 }
13
14 addClient (id, platform, client) {
15 this.clients.set(id, { platform, client })
16 }
17
18 removeClient (id) {
19 this.clients.delete(id)
20 }
21
22 getClients (platform) {
23 const result = []
24 for (const [id, entry] of this.clients) {
25 if (!platform || entry.platform === platform) {
26 result.push({ id, ...entry })
27 }
28 }
29 return result
30 }
31
32 /**
33 * Publish content to selected platforms
34 * @param {string} text - Post text
35 * @param {string[]} targets - Platform names to publish to (e.g., ['mastodon', 'atproto'])
36 * @param {object} opts - Additional options (media, replyTo, etc.)
37 */
38 async publish (text, targets = [], opts = {}) {
39 if (!text || !text.trim()) throw new Error('Text is required')
40
41 const results = {}
42 const errors = {}
43
44 // media: { buffer, mimeType, width, height } from processMedia()
45 const media = opts.media || null
46
47 for (const target of targets) {
48 const clients = this.getClients(target)
49 if (clients.length === 0) {
50 errors[target] = 'No connected account'
51 continue
52 }
53
54 for (const { id, client } of clients) {
55 try {
56 if (target === 'mastodon') {
57 const postOpts = { visibility: opts.visibility || 'public' }
58
59 if (media) {
60 const uploaded = await client.uploadMedia(media.buffer, media.mimeType, opts.altText || '')
61 postOpts.mediaIds = [uploaded.id]
62 }
63
64 const status = await client.postStatus(text, postOpts)
65 results[id] = { platform: target, id: status.id, url: status.url }
66 } else if (target === 'atproto') {
67 const postOpts = {}
68
69 if (media) {
70 const uploaded = await client.uploadBlob(media.buffer, media.mimeType)
71 postOpts.images = [{
72 blob: uploaded.blob,
73 alt: opts.altText || '',
74 width: media.width,
75 height: media.height
76 }]
77 }
78
79 const record = await client.post(text, postOpts)
80 results[id] = { platform: target, uri: record.uri, cid: record.cid }
81 }
82 } catch (err) {
83 errors[id] = err.message
84 }
85 }
86 }
87
88 // Store in feed
89 const item = await this.feedStore.add({
90 source: 'local',
91 author: 'me',
92 text,
93 type: media ? 'media' : 'post',
94 mediaUrl: media ? '(uploaded)' : null,
95 mediaType: media ? media.mimeType : null,
96 syndicated: results,
97 metadata: { targets, errors: Object.keys(errors).length > 0 ? errors : undefined }
98 })
99
100 return { item, results, errors }
101 }
102
103 /**
104 * Refresh — pull latest posts from all connected platforms into feed store
105 */
106 async refresh () {
107 let count = 0
108
109 for (const [id, { platform, client }] of this.clients) {
110 try {
111 if (platform === 'mastodon') {
112 count += await this._refreshMastodon(id, client)
113 } else if (platform === 'atproto') {
114 count += await this._refreshAtproto(id, client)
115 }
116 } catch (err) {
117 console.error(`[posse] Refresh ${id} failed:`, err.message)
118 }
119 }
120
121 return count
122 }
123
124 async _refreshMastodon (accountId, client) {
125 const statuses = await client.homeTimeline(30)
126 let count = 0
127
128 for (const status of statuses) {
129 const actual = status.reblog || status
130 const author = actual.account?.acct || actual.account?.username || 'unknown'
131 const text = stripHtml(actual.content || '')
132
133 if (!text && !actual.media_attachments?.length) continue
134
135 // Deduplicate by platform-specific ID
136 const itemId = `mastodon:${actual.id}`
137 const existing = await this.feedStore.get(itemId)
138 if (existing) continue
139
140 const attachment = actual.media_attachments?.[0]
141
142 await this.feedStore.add({
143 id: itemId,
144 source: 'mastodon',
145 author,
146 authorId: `mastodon:${actual.account?.id || author}`,
147 text,
148 timestamp: new Date(actual.created_at).getTime(),
149 type: status.reblog ? 'boost' : (actual.in_reply_to_id ? 'reply' : 'post'),
150 mediaUrl: attachment?.url || null,
151 mediaType: attachment?.type || null,
152 tags: (actual.tags || []).map(t => t.name),
153 url: actual.url,
154 metadata: {
155 statusId: actual.id,
156 avatar: actual.account?.avatar_static || actual.account?.avatar || null,
157 displayName: actual.account?.display_name || '',
158 spoilerText: actual.spoiler_text || '',
159 isBoost: !!status.reblog,
160 boostBy: status.reblog ? (status.account?.acct || '') : '',
161 boostByAvatar: status.reblog ? (status.account?.avatar_static || status.account?.avatar || null) : null,
162 visibility: actual.visibility
163 }
164 })
165 count++
166 }
167
168 return count
169 }
170
171 async _refreshAtproto (accountId, client) {
172 const feed = await client.getTimeline(30)
173 let count = 0
174
175 for (const item of feed) {
176 const post = item.post
177 if (!post) continue
178
179 const author = post.author?.handle || post.author?.did || 'unknown'
180 const text = post.record?.text || ''
181 if (!text) continue
182
183 const itemId = `atproto:${post.uri}`
184 const existing = await this.feedStore.get(itemId)
185 if (existing) continue
186
187 const embed = post.embed
188 let mediaUrl = null
189 let mediaType = null
190 if (embed?.images?.[0]) {
191 mediaUrl = embed.images[0].fullsize || embed.images[0].thumb
192 mediaType = 'image'
193 }
194
195 await this.feedStore.add({
196 id: itemId,
197 source: 'atproto',
198 author,
199 authorId: `atproto:${post.author?.did || author}`,
200 text,
201 timestamp: new Date(post.record?.createdAt || post.indexedAt).getTime(),
202 type: item.reason?.$type === 'app.bsky.feed.defs#reasonRepost' ? 'boost' : (post.record?.reply ? 'reply' : 'post'),
203 mediaUrl,
204 mediaType,
205 tags: [],
206 url: `https://bsky.app/profile/${post.author?.handle}/post/${post.uri.split('/').pop()}`,
207 metadata: {
208 uri: post.uri,
209 cid: post.cid,
210 avatar: post.author?.avatar || null,
211 displayName: post.author?.displayName || '',
212 likeCount: post.likeCount || 0,
213 repostCount: post.repostCount || 0,
214 replyCount: post.replyCount || 0
215 }
216 })
217 count++
218 }
219
220 return count
221 }
222
223 /**
224 * Get notifications from all connected platforms
225 */
226 async notifications () {
227 const all = []
228
229 for (const [id, { platform, client }] of this.clients) {
230 try {
231 if (platform === 'mastodon') {
232 const notifs = await client.notifications(20)
233 for (const n of notifs) {
234 all.push({
235 platform: 'mastodon',
236 type: n.type, // mention, reblog, favourite, follow
237 author: n.account?.acct || 'unknown',
238 text: n.status ? stripHtml(n.status.content || '') : '',
239 timestamp: new Date(n.created_at).getTime()
240 })
241 }
242 } else if (platform === 'atproto') {
243 const notifs = await client.getNotifications(20)
244 for (const n of notifs) {
245 all.push({
246 platform: 'atproto',
247 type: n.reason, // like, repost, follow, mention, reply
248 author: n.author?.handle || 'unknown',
249 text: n.record?.text || '',
250 timestamp: new Date(n.indexedAt).getTime()
251 })
252 }
253 }
254 } catch (err) {
255 console.error(`[posse] Notifications ${id} failed:`, err.message)
256 }
257 }
258
259 // Sort by timestamp descending
260 all.sort((a, b) => b.timestamp - a.timestamp)
261 return all.slice(0, 50)
262 }
263}
264
265function stripHtml (html) {
266 return html
267 .replace(/<br\s*\/?>/gi, '\n')
268 .replace(/<\/p>\s*<p>/gi, '\n\n')
269 .replace(/<[^>]+>/g, '')
270 .replace(/&amp;/g, '&')
271 .replace(/&lt;/g, '<')
272 .replace(/&gt;/g, '>')
273 .replace(/&quot;/g, '"')
274 .replace(/&#39;/g, "'")
275 .trim()
276}
277
278export { POSSEEngine }
hyper-social-web/lib/server.js +380 -0
……@@ -0,0 +1,380 @@
1import http from 'bare-http1'
2import fs from 'bare-fs'
3import path from 'bare-path'
4import os from 'bare-os'
5import Corestore from 'corestore'
6import Hyperbee from 'hyperbee'
7
8import { FeedStore } from './feed-store.js'
9import { POSSEEngine } from './posse.js'
10import { MastodonClient } from './clients/mastodon-client.js'
11import { AtprotoClient } from './clients/atproto-client.js'
12import { processMedia } from './media.js'
13
14const MIME = {
15 '.html': 'text/html; charset=utf-8',
16 '.css': 'text/css; charset=utf-8',
17 '.js': 'application/javascript; charset=utf-8',
18 '.json': 'application/json; charset=utf-8',
19 '.png': 'image/png',
20 '.svg': 'image/svg+xml',
21 '.ico': 'image/x-icon'
22}
23
24function json (res, data, status = 200) {
25 res.statusCode = status
26 res.setHeader('Content-Type', 'application/json; charset=utf-8')
27 res.end(JSON.stringify(data))
28}
29
30function error (res, msg, status = 400) {
31 json(res, { error: msg }, status)
32}
33
34async function bodyJson (req) {
35 return new Promise((resolve, reject) => {
36 let data = ''
37 req.on('data', (chunk) => { data += chunk.toString() })
38 req.on('end', () => {
39 try { resolve(JSON.parse(data)) } catch { reject(new Error('Invalid JSON')) }
40 })
41 req.on('error', reject)
42 })
43}
44
45async function parseMultipart (req) {
46 const contentType = req.headers['content-type'] || ''
47 const boundaryMatch = contentType.match(/boundary=(.+)/)
48 if (!boundaryMatch) throw new Error('No multipart boundary')
49
50 const boundary = boundaryMatch[1]
51
52 return new Promise((resolve, reject) => {
53 const chunks = []
54 req.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
55 req.on('end', () => {
56 const body = Buffer.concat(chunks)
57 const fields = {}
58 let file = null
59
60 const boundaryBuf = Buffer.from('--' + boundary)
61 const parts = splitBuffer(body, boundaryBuf)
62
63 for (const part of parts) {
64 const headerEnd = bufferIndexOf(part, Buffer.from('\r\n\r\n'))
65 if (headerEnd === -1) continue
66
67 const headerStr = part.slice(0, headerEnd).toString()
68 const content = part.slice(headerEnd + 4)
69
70 // Strip trailing \r\n
71 const trimmed = content.length > 2 && content[content.length - 2] === 13 && content[content.length - 1] === 10
72 ? content.slice(0, -2) : content
73
74 const nameMatch = headerStr.match(/name="([^"]+)"/)
75 const filenameMatch = headerStr.match(/filename="([^"]+)"/)
76 const typeMatch = headerStr.match(/Content-Type:\s*(\S+)/i)
77
78 if (!nameMatch) continue
79
80 if (filenameMatch) {
81 file = {
82 fieldName: nameMatch[1],
83 filename: filenameMatch[1],
84 mimeType: typeMatch ? typeMatch[1] : 'application/octet-stream',
85 buffer: trimmed
86 }
87 } else {
88 fields[nameMatch[1]] = trimmed.toString()
89 }
90 }
91
92 resolve({ fields, file })
93 })
94 req.on('error', reject)
95 })
96}
97
98function splitBuffer (buf, delimiter) {
99 const parts = []
100 let start = 0
101 while (true) {
102 const idx = bufferIndexOf(buf, delimiter, start)
103 if (idx === -1) {
104 if (start < buf.length) parts.push(buf.slice(start))
105 break
106 }
107 if (idx > start) parts.push(buf.slice(start, idx))
108 start = idx + delimiter.length
109 }
110 return parts
111}
112
113function bufferIndexOf (buf, needle, start = 0) {
114 for (let i = start; i <= buf.length - needle.length; i++) {
115 let found = true
116 for (let j = 0; j < needle.length; j++) {
117 if (buf[i + j] !== needle[j]) { found = false; break }
118 }
119 if (found) return i
120 }
121 return -1
122}
123
124async function createServer (opts = {}) {
125 const { port = 5050, prefix = '', storage } = opts
126
127 const appDir = path.dirname(path.dirname(new URL(import.meta.url).pathname))
128 const webDir = path.join(appDir, 'web')
129
130 // Storage
131 const storageDir = storage || path.join(os.homedir(), '.config', 'hyper-social-web')
132 try { fs.mkdirSync(storageDir, { recursive: true }) } catch {}
133
134 const store = new Corestore(path.join(storageDir, 'corestore'))
135 await store.ready()
136
137 const bee = new Hyperbee(store.get({ name: 'feed' }), {
138 keyEncoding: 'utf-8',
139 valueEncoding: 'json'
140 })
141 await bee.ready()
142
143 const feedStore = new FeedStore(bee)
144 const posse = new POSSEEngine(feedStore)
145
146 // Load saved accounts
147 const accountsBee = new Hyperbee(store.get({ name: 'accounts' }), {
148 keyEncoding: 'utf-8',
149 valueEncoding: 'json'
150 })
151 await accountsBee.ready()
152 await loadAccounts(accountsBee, posse)
153
154 // HTTP server
155 const server = http.createServer(async (req, res) => {
156 let url = req.url
157
158 // Strip prefix (Apache ProxyPass keeps the prefix in the path)
159 if (prefix && url.startsWith(prefix)) {
160 url = url.slice(prefix.length)
161 if (!url || url[0] !== '/') url = '/' + url
162 }
163
164 // CORS for local dev
165 res.setHeader('Access-Control-Allow-Origin', '*')
166 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
167 res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
168 if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }
169
170 try {
171 // --- API routes ---
172 if (url.startsWith('/api/')) {
173 await handleApi(url, req, res, { posse, feedStore, accountsBee, prefix })
174 return
175 }
176
177 // --- Static files ---
178 await serveStatic(url, res, webDir)
179 } catch (err) {
180 console.error('[server] Error:', err.message)
181 error(res, 'Internal server error', 500)
182 }
183 })
184
185 server.listen(port, '0.0.0.0')
186 return { server, store, posse, feedStore }
187}
188
189async function handleApi (url, req, res, ctx) {
190 const { posse, feedStore, accountsBee, prefix } = ctx
191 const method = req.method
192
193 // POST /api/post — POSSE publish (text only, JSON body)
194 if (url === '/api/post' && method === 'POST') {
195 const body = await bodyJson(req)
196 const result = await posse.publish(body.text, body.targets || [], body)
197 json(res, result)
198 return
199 }
200
201 // POST /api/post-media — POSSE publish with media (multipart)
202 if (url === '/api/post-media' && method === 'POST') {
203 const { fields, file } = await parseMultipart(req)
204
205 if (!file) { error(res, 'No file uploaded'); return }
206
207 const text = fields.text || ''
208 const targets = fields.targets ? JSON.parse(fields.targets) : []
209 const altText = fields.altText || ''
210
211 if (!text.trim()) { error(res, 'Text is required'); return }
212 if (targets.length === 0) { error(res, 'Select at least one platform'); return }
213
214 // Process media (resize, compress, strip metadata)
215 const processed = await processMedia(file.buffer, file.filename, file.mimeType)
216
217 const result = await posse.publish(text, targets, {
218 media: processed,
219 altText
220 })
221
222 json(res, result)
223 return
224 }
225
226 // GET /api/timeline — unified timeline
227 if (url.startsWith('/api/timeline')) {
228 const params = new URL('http://x' + url).searchParams
229 const source = params.get('source') || undefined
230 const limit = Number(params.get('limit')) || 50
231 const before = params.get('before') ? Number(params.get('before')) : undefined
232
233 const items = await feedStore.timeline({ source, limit, before })
234 json(res, { items })
235 return
236 }
237
238 // GET /api/accounts — list connected accounts
239 if (url === '/api/accounts' && method === 'GET') {
240 const accounts = []
241 for await (const node of accountsBee.createReadStream({ gte: 'account/', lte: 'account/~' })) {
242 if (node.value) {
243 const safe = { ...node.value }
244 delete safe.token
245 delete safe.password
246 accounts.push(safe)
247 }
248 }
249 json(res, { accounts })
250 return
251 }
252
253 // POST /api/accounts/connect — connect a new account
254 if (url === '/api/accounts/connect' && method === 'POST') {
255 const body = await bodyJson(req)
256 const { platform, instance, handle, token, password } = body
257
258 if (!platform) { error(res, 'platform required'); return }
259
260 const id = `${platform}:${handle || instance || 'default'}`
261 const account = { id, platform, instance, handle, token, password, connectedAt: Date.now() }
262
263 await accountsBee.put(`account/${id}`, account)
264
265 // Register client with POSSE engine
266 await connectAccount(account, posse)
267
268 const safe = { ...account }
269 delete safe.token
270 delete safe.password
271 json(res, { account: safe })
272 return
273 }
274
275 // DELETE /api/accounts/:id
276 if (url.startsWith('/api/accounts/') && method === 'DELETE') {
277 const id = decodeURIComponent(url.slice('/api/accounts/'.length))
278 await accountsBee.del(`account/${id}`)
279 posse.removeClient(id)
280 json(res, { ok: true })
281 return
282 }
283
284 // GET /api/notifications
285 if (url === '/api/notifications' && method === 'GET') {
286 const notifications = await posse.notifications()
287 json(res, { notifications })
288 return
289 }
290
291 // GET /api/feed/refresh — pull latest from all platforms
292 if (url === '/api/feed/refresh' && method === 'GET') {
293 const count = await posse.refresh()
294 json(res, { refreshed: count })
295 return
296 }
297
298 // POST /api/feed/repull — clear feed and re-pull (to pick up avatars etc)
299 if (url === '/api/feed/repull' && method === 'POST') {
300 // Delete all timeline items
301 let deleted = 0
302 for await (const node of feedStore.bee.createReadStream({ gte: 'item/', lte: 'item/~' })) {
303 if (node.value?.id) {
304 const item = node.value
305 const batch = feedStore.bee.batch()
306 await batch.del(`item/${item.id}`)
307 await batch.del(`timeline/${feedStore._padTimestamp(item.timestamp)}/${item.id}`)
308 await batch.del(`source/${item.source}/${feedStore._padTimestamp(item.timestamp)}/${item.id}`)
309 await batch.del(`author/${item.authorId}/${feedStore._padTimestamp(item.timestamp)}/${item.id}`)
310 await batch.flush()
311 deleted++
312 }
313 }
314 // Re-pull
315 const count = await posse.refresh()
316 json(res, { deleted, refreshed: count })
317 return
318 }
319
320 error(res, 'Not found', 404)
321}
322
323async function serveStatic (url, res, webDir) {
324 let filePath = url === '/' ? '/index.html' : url
325
326 // SPA fallback — serve index.html for non-file paths
327 const ext = path.extname(filePath)
328 if (!ext) filePath = '/index.html'
329
330 const fullPath = path.join(webDir, filePath)
331
332 try {
333 const content = fs.readFileSync(fullPath)
334 const mime = MIME[path.extname(fullPath)] || 'application/octet-stream'
335 res.setHeader('Content-Type', mime)
336 res.end(content)
337 } catch {
338 // SPA fallback for any path
339 try {
340 const index = fs.readFileSync(path.join(webDir, 'index.html'))
341 res.setHeader('Content-Type', 'text/html; charset=utf-8')
342 res.end(index)
343 } catch {
344 res.statusCode = 404
345 res.end('Not found')
346 }
347 }
348}
349
350async function loadAccounts (accountsBee, posse) {
351 for await (const node of accountsBee.createReadStream({ gte: 'account/', lte: 'account/~' })) {
352 if (node.value) {
353 try {
354 await connectAccount(node.value, posse)
355 } catch (err) {
356 console.error(`[server] Failed to connect ${node.value.id}:`, err.message)
357 }
358 }
359 }
360}
361
362async function connectAccount (account, posse) {
363 if (account.platform === 'mastodon') {
364 const client = new MastodonClient({
365 instance: account.instance,
366 token: account.token
367 })
368 posse.addClient(account.id, 'mastodon', client)
369 } else if (account.platform === 'atproto') {
370 const client = new AtprotoClient({
371 service: account.instance || 'https://bsky.social',
372 identifier: account.handle,
373 password: account.password
374 })
375 await client.login()
376 posse.addClient(account.id, 'atproto', client)
377 }
378}
379
380export { createServer }
hyper-social-web/package-lock.json +1608 -0
……@@ -0,0 +1,1608 @@
1{
2 "name": "hyper-social-web",
3 "version": "0.1.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "hyper-social-web",
9 "version": "0.1.0",
10 "dependencies": {
11 "b4a": "^1.8.0",
12 "bare-crypto": "^1.13.4",
13 "bare-ffmpeg": "^1.2.1",
14 "bare-fs": "^4.5.6",
15 "bare-http1": "^4.5.5",
16 "bare-https": "^2.1.3",
17 "bare-node-runtime": "^1.2.0",
18 "bare-os": "^3.8.0",
19 "bare-path": "^3.0.0",
20 "bare-subprocess": "^5.2.3",
21 "bridge-envelope": "file:../bridge-envelope",
22 "bridge-vault": "file:../bridge-vault",
23 "corestore": "^7.9.2",
24 "hyper-feed": "file:../hyper-feed",
25 "hyperbee": "^2.27.3"
26 }
27 },
28 "../bridge-envelope": {
29 "version": "1.0.0",
30 "license": "MIT",
31 "dependencies": {
32 "b4a": "^1.0.0",
33 "compact-encoding": "^2.0.0"
34 },
35 "devDependencies": {
36 "brittle": "^3.0.0"
37 }
38 },
39 "../bridge-vault": {
40 "version": "1.0.0",
41 "license": "MIT",
42 "dependencies": {
43 "b4a": "^1.0.0",
44 "compact-encoding": "^2.0.0",
45 "corestore": "^6.0.0",
46 "hyperbee": "^2.0.0",
47 "hypercore": "^10.0.0",
48 "sodium-native": "^4.0.0"
49 },
50 "devDependencies": {
51 "brittle": "^3.0.0"
52 }
53 },
54 "../hyper-feed": {
55 "version": "1.0.0",
56 "license": "MIT",
57 "dependencies": {
58 "corestore": "^6.0.0",
59 "hyperbee": "^2.0.0"
60 },
61 "devDependencies": {
62 "brittle": "^3.0.0"
63 }
64 },
65 "node_modules/@hyperswarm/secret-stream": {
66 "version": "6.9.1",
67 "resolved": "https://registry.npmjs.org/@hyperswarm/secret-stream/-/secret-stream-6.9.1.tgz",
68 "integrity": "sha512-xb0S5y3YJwBakD77JOGBHlBxdp63mHClZoXBYoLv+9wH8e054ESKlmQptWqjJK5dv5VMUIVYOJB4MaOpB0JdGw==",
69 "license": "Apache-2.0",
70 "dependencies": {
71 "b4a": "^1.1.0",
72 "hypercore-crypto": "^3.3.1",
73 "noise-curve-ed": "^2.0.1",
74 "noise-handshake": "^4.0.0",
75 "sodium-secretstream": "^1.1.0",
76 "sodium-universal": "^5.0.0",
77 "streamx": "^2.14.0",
78 "timeout-refresh": "^2.0.0",
79 "unslab": "^1.3.0"
80 }
81 },
82 "node_modules/b4a": {
83 "version": "1.8.0",
84 "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
85 "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
86 "license": "Apache-2.0",
87 "peerDependencies": {
88 "react-native-b4a": "*"
89 },
90 "peerDependenciesMeta": {
91 "react-native-b4a": {
92 "optional": true
93 }
94 }
95 },
96 "node_modules/bare-abort": {
97 "version": "2.0.13",
98 "resolved": "https://registry.npmjs.org/bare-abort/-/bare-abort-2.0.13.tgz",
99 "integrity": "sha512-zdc8l88eB11Jsz5rDd6sCAgv2kUFXgdrZWoMlgU6JMkfAi1/uuGFC3IEHswKbIRQTk5H3T5CMuechsXYxiaHlQ==",
100 "license": "Apache-2.0"
101 },
102 "node_modules/bare-abort-controller": {
103 "version": "1.1.0",
104 "resolved": "https://registry.npmjs.org/bare-abort-controller/-/bare-abort-controller-1.1.0.tgz",
105 "integrity": "sha512-ycBxndojKqfYfGTX2NgZP1YnCCV3w5d4Co2BnKTN8ZKEmTyPPKMuEHrVKtsWlqBzzZws9UsLMcdmJf7a/37lWw==",
106 "license": "Apache-2.0",
107 "peer": true,
108 "dependencies": {
109 "bare-events": "^2.7.0"
110 }
111 },
112 "node_modules/bare-addon-resolve": {
113 "version": "1.10.0",
114 "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz",
115 "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==",
116 "license": "Apache-2.0",
117 "dependencies": {
118 "bare-module-resolve": "^1.10.0",
119 "bare-semver": "^1.0.0"
120 },
121 "peerDependencies": {
122 "bare-url": "*"
123 },
124 "peerDependenciesMeta": {
125 "bare-url": {
126 "optional": true
127 }
128 }
129 },
130 "node_modules/bare-ansi-escapes": {
131 "version": "2.2.3",
132 "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz",
133 "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==",
134 "license": "Apache-2.0",
135 "dependencies": {
136 "bare-stream": "^2.6.5"
137 },
138 "peerDependencies": {
139 "bare-buffer": "*"
140 },
141 "peerDependenciesMeta": {
142 "bare-buffer": {
143 "optional": true
144 }
145 }
146 },
147 "node_modules/bare-assert": {
148 "version": "1.2.0",
149 "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz",
150 "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==",
151 "license": "Apache-2.0",
152 "dependencies": {
153 "bare-inspect": "^3.1.2"
154 }
155 },
156 "node_modules/bare-async-hooks": {
157 "version": "0.0.0",
158 "resolved": "https://registry.npmjs.org/bare-async-hooks/-/bare-async-hooks-0.0.0.tgz",
159 "integrity": "sha512-xNfGwUobaomCGMGAqohAekS3uMCj+4tvI4AoOaJnO7NfpN+dvFdkC5xkeQtmZzs2vxf2TR5J6i5FDd1ImCZERw==",
160 "license": "Apache-2.0"
161 },
162 "node_modules/bare-buffer": {
163 "version": "3.6.0",
164 "resolved": "https://registry.npmjs.org/bare-buffer/-/bare-buffer-3.6.0.tgz",
165 "integrity": "sha512-/maRWEQ2eBkVNMbNFVsq1pHXJYVj4Y3AixwruB24eKZDs5Gtu0fixzvjYmBIuTsBMtVH5Yb27pQO9BhFa+IlIQ==",
166 "license": "Apache-2.0",
167 "engines": {
168 "bare": ">=1.20.0"
169 }
170 },
171 "node_modules/bare-bundle": {
172 "version": "1.10.0",
173 "resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz",
174 "integrity": "sha512-4LVlnJAHr00Hh6Vu6ZUJS38rcEtJT3b3vChXSsBsJ2mk1TN0lQ+gzd+Dw5L0aV7uqDZv84smuwW+O02X7PfDlw==",
175 "license": "Apache-2.0",
176 "peerDependencies": {
177 "bare-buffer": "*",
178 "bare-url": "*"
179 },
180 "peerDependenciesMeta": {
181 "bare-buffer": {
182 "optional": true
183 },
184 "bare-url": {
185 "optional": true
186 }
187 }
188 },
189 "node_modules/bare-channel": {
190 "version": "5.2.3",
191 "resolved": "https://registry.npmjs.org/bare-channel/-/bare-channel-5.2.3.tgz",
192 "integrity": "sha512-2cRErqS4fzzr3ZUSd5W67ka70dZKJ1VWrCsvxPVC8vr3rWvd4aaCG5iCDas9+MFVaUIWvfw04+NH4+FHQDddMQ==",
193 "license": "Apache-2.0",
194 "dependencies": {
195 "bare-events": "^2.0.0",
196 "bare-stream": "^2.7.0",
197 "bare-structured-clone": "^1.4.0"
198 },
199 "engines": {
200 "bare": ">=1.7.0"
201 }
202 },
203 "node_modules/bare-console": {
204 "version": "6.1.0",
205 "resolved": "https://registry.npmjs.org/bare-console/-/bare-console-6.1.0.tgz",
206 "integrity": "sha512-BziGTEIyVh0zn78842mmpOLGTEFE7/S4A5Tb0d2iIu7bryVdoiF0przraYMetJckHxm0P5ITTZrYfRuYRr9n/Q==",
207 "license": "Apache-2.0",
208 "dependencies": {
209 "bare-hrtime": "^2.0.0",
210 "bare-logger": "^2.0.0",
211 "bare-system-logger": "^1.0.2"
212 }
213 },
214 "node_modules/bare-crypto": {
215 "version": "1.13.4",
216 "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.4.tgz",
217 "integrity": "sha512-JiCZ5l2YOG1y8J7yy1BCAKTCZrPnHLb7pDRIdurBTOn5oIwBQDIv8iH5Pl2V85vzjl1NZXRfNY4HZLsE942jJA==",
218 "license": "Apache-2.0",
219 "dependencies": {
220 "bare-assert": "^1.2.0",
221 "bare-stream": "^2.6.3"
222 },
223 "peerDependencies": {
224 "bare-buffer": "*"
225 },
226 "peerDependenciesMeta": {
227 "bare-buffer": {
228 "optional": true
229 }
230 }
231 },
232 "node_modules/bare-debug-log": {
233 "version": "2.0.0",
234 "resolved": "https://registry.npmjs.org/bare-debug-log/-/bare-debug-log-2.0.0.tgz",
235 "integrity": "sha512-Vi42PkMQsNV9PUpx2Gl1hikshx5O9FzMJ6o9Nnopseg7qLBBK7Nl31d0RHcfwLEAfmcPApytpc0ZFfq68u22FQ==",
236 "license": "Apache-2.0",
237 "dependencies": {
238 "bare-os": "^3.0.1"
239 }
240 },
241 "node_modules/bare-dgram": {
242 "version": "1.0.1",
243 "resolved": "https://registry.npmjs.org/bare-dgram/-/bare-dgram-1.0.1.tgz",
244 "integrity": "sha512-EdsyRErrkWgN8fENdrDdXFEE9HAuJ/m6ehXz13fVj9JhdCaLWIA+L8o5aYNRLt66x08RlyG2vbrRAZoxGfcdlg==",
245 "license": "Apache-2.0",
246 "dependencies": {
247 "bare-events": "^2.5.0",
248 "udx-native": "^1.11.2"
249 }
250 },
251 "node_modules/bare-diagnostics-channel": {
252 "version": "1.1.0",
253 "resolved": "https://registry.npmjs.org/bare-diagnostics-channel/-/bare-diagnostics-channel-1.1.0.tgz",
254 "integrity": "sha512-Reu+EQo+eLpB+a5p8UykFEdXndFaRaSgKV38uAMh/qhE2eTeJcdwAwo74hKYyeN8GD3DIFqC9ZlM4bnDc03CIg==",
255 "license": "Apache-2.0"
256 },
257 "node_modules/bare-dns": {
258 "version": "2.1.4",
259 "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.1.4.tgz",
260 "integrity": "sha512-abwjHmpWqSRNB7V5615QxPH92L71AVzFm/kKTs8VYiNTAi2xVdonpv0BjJ0hwXLwomoW+xsSOPjW6PZPO14asg==",
261 "license": "Apache-2.0",
262 "engines": {
263 "bare": ">=1.7.0"
264 }
265 },
266 "node_modules/bare-encoding": {
267 "version": "1.0.3",
268 "resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.3.tgz",
269 "integrity": "sha512-Kqf+t/azs13lUeyK4Tb7ha4wdLRXKWCXQ8w1rVmt7KtoPCPdHD/Xwt7LBIsCSwwGglrcmblo5VOLa5avkJqULA==",
270 "license": "Apache-2.0",
271 "peerDependencies": {
272 "bare-buffer": "*"
273 },
274 "peerDependenciesMeta": {
275 "bare-buffer": {
276 "optional": true
277 }
278 }
279 },
280 "node_modules/bare-env": {
281 "version": "3.0.0",
282 "resolved": "https://registry.npmjs.org/bare-env/-/bare-env-3.0.0.tgz",
283 "integrity": "sha512-0u964P5ZLAxTi+lW4Kjp7YRJQ5gZr9ycYOtjLxsSrupgMz3sn5Z9n4SH/JIifHwvadsf1brA2JAjP+9IOWwTiw==",
284 "license": "Apache-2.0",
285 "dependencies": {
286 "bare-os": "^3.0.1"
287 }
288 },
289 "node_modules/bare-events": {
290 "version": "2.8.2",
291 "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
292 "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
293 "license": "Apache-2.0",
294 "peerDependencies": {
295 "bare-abort-controller": "*"
296 },
297 "peerDependenciesMeta": {
298 "bare-abort-controller": {
299 "optional": true
300 }
301 }
302 },
303 "node_modules/bare-fetch": {
304 "version": "2.8.0",
305 "resolved": "https://registry.npmjs.org/bare-fetch/-/bare-fetch-2.8.0.tgz",
306 "integrity": "sha512-xCBF6oKwzZmq+M6FGcAHv8Fn3XEXHeauLr6dfmr5IG07PFhabg1+r9eeVaIybPAqx8syF9M9+4r23QuDvh3Uvg==",
307 "license": "Apache-2.0",
308 "dependencies": {
309 "bare-form-data": "^1.2.0",
310 "bare-http1": "^4.5.2",
311 "bare-https": "^2.0.0",
312 "bare-stream": "^2.9.1",
313 "bare-url": "^2.4.0",
314 "bare-zlib": "^1.3.0"
315 },
316 "peerDependencies": {
317 "bare-abort-controller": "*",
318 "bare-buffer": "*"
319 },
320 "peerDependenciesMeta": {
321 "bare-abort-controller": {
322 "optional": true
323 },
324 "bare-buffer": {
325 "optional": true
326 }
327 }
328 },
329 "node_modules/bare-ffmpeg": {
330 "version": "1.2.1",
331 "resolved": "https://registry.npmjs.org/bare-ffmpeg/-/bare-ffmpeg-1.2.1.tgz",
332 "integrity": "sha512-lGZS7eRCEoCCw0Jnv1PPYl2N8SHuzfMDuKuw+fMDKWlauBZ7tnTtYOQrewLvDjww6TMXhtt2KMiw1OXSFU74Xw==",
333 "license": "Apache-2.0"
334 },
335 "node_modules/bare-form-data": {
336 "version": "1.2.1",
337 "resolved": "https://registry.npmjs.org/bare-form-data/-/bare-form-data-1.2.1.tgz",
338 "integrity": "sha512-DFZhp5Tn0S7TjII/xhgLl2BNU6f7l5QbIW/YH7TjzQVnxLa8SaMSbl77jpy0ZR1oUnAo0a7NHJ9QAOU6PPr0Jg==",
339 "license": "Apache-2.0",
340 "dependencies": {
341 "bare-buffer": "^3.6.0",
342 "bare-stream": "^2.6.5"
343 }
344 },
345 "node_modules/bare-format": {
346 "version": "1.0.2",
347 "resolved": "https://registry.npmjs.org/bare-format/-/bare-format-1.0.2.tgz",
348 "integrity": "sha512-GswdhnOnP9QtwRbrf4wLApw5widkaLMsLe2XOs35fQD2YfEN1ApoGka+cZ7PfvzxMgfYXmMhj/2OGlVn5/Dxgw==",
349 "license": "Apache-2.0",
350 "dependencies": {
351 "bare-inspect": "^3.0.0"
352 }
353 },
354 "node_modules/bare-fs": {
355 "version": "4.5.6",
356 "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz",
357 "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==",
358 "license": "Apache-2.0",
359 "dependencies": {
360 "bare-events": "^2.5.4",
361 "bare-path": "^3.0.0",
362 "bare-stream": "^2.6.4",
363 "bare-url": "^2.2.2",
364 "fast-fifo": "^1.3.2"
365 },
366 "engines": {
367 "bare": ">=1.16.0"
368 },
369 "peerDependencies": {
370 "bare-buffer": "*"
371 },
372 "peerDependenciesMeta": {
373 "bare-buffer": {
374 "optional": true
375 }
376 }
377 },
378 "node_modules/bare-hrtime": {
379 "version": "2.1.1",
380 "resolved": "https://registry.npmjs.org/bare-hrtime/-/bare-hrtime-2.1.1.tgz",
381 "integrity": "sha512-VMb3tHo05gsnbu3OXTmkDiwTjMlOsbQmKoysKqKEyR09m77TuDrYFbj3Q5GGk10dAKsUHrnXmwCaeJqzVpB5ZA==",
382 "license": "Apache-2.0"
383 },
384 "node_modules/bare-http-parser": {
385 "version": "1.1.3",
386 "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.3.tgz",
387 "integrity": "sha512-+dhVvQi6brHq14L/XHNRQ+TLuVE76VjRmMt61wVEtS+Od8xUslfMHWJN/ZjIIt3RtTG6vPuA+x9cOh7KrkBJsA==",
388 "license": "Apache-2.0"
389 },
390 "node_modules/bare-http1": {
391 "version": "4.5.5",
392 "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.5.5.tgz",
393 "integrity": "sha512-ADITiRo0huP76JGMbv6Arsh9KehHqjEBoYcmjvAo67IY78+/9mV2MeKLLCkiJSFxA85T/m8cTaE44OwJQCcwdw==",
394 "license": "Apache-2.0",
395 "dependencies": {
396 "bare-events": "^2.6.0",
397 "bare-http-parser": "^1.1.1",
398 "bare-stream": "^2.10.0",
399 "bare-tcp": "^2.2.0"
400 },
401 "peerDependencies": {
402 "bare-buffer": "*",
403 "bare-url": "*"
404 },
405 "peerDependenciesMeta": {
406 "bare-buffer": {
407 "optional": true
408 },
409 "bare-url": {
410 "optional": true
411 }
412 }
413 },
414 "node_modules/bare-https": {
415 "version": "2.1.3",
416 "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-2.1.3.tgz",
417 "integrity": "sha512-0TI/mJXQGYXmG7UUyWEG+KCJusayIAQLywUjFAskDoKuxfqVnGF+M/mTMrEV8J64DaIdU+5x761FvgmT7M68tA==",
418 "license": "Apache-2.0",
419 "dependencies": {
420 "bare-http1": "^4.4.0",
421 "bare-tcp": "^2.2.0",
422 "bare-tls": "^2.0.0"
423 }
424 },
425 "node_modules/bare-inspect": {
426 "version": "3.1.4",
427 "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz",
428 "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==",
429 "license": "Apache-2.0",
430 "dependencies": {
431 "bare-ansi-escapes": "^2.1.0",
432 "bare-type": "^1.0.0"
433 },
434 "engines": {
435 "bare": ">=1.18.0"
436 }
437 },
438 "node_modules/bare-inspector": {
439 "version": "5.0.0",
440 "resolved": "https://registry.npmjs.org/bare-inspector/-/bare-inspector-5.0.0.tgz",
441 "integrity": "sha512-blObRFPBK6yn0/wZbrRgkGcOuLUQUYVvkKbsEp5p3bWJB+uNrAh+qUqOijQHS6mZjGvyGCE86VERp3EiUR2TyA==",
442 "license": "Apache-2.0",
443 "dependencies": {
444 "bare-events": "^2.1.0",
445 "bare-http1": "^4.0.0",
446 "bare-stream": "^2.0.0",
447 "bare-url": "^2.0.0",
448 "bare-ws": "^2.0.0"
449 },
450 "engines": {
451 "bare": ">=1.2.0"
452 },
453 "peerDependencies": {
454 "bare-tcp": "*"
455 },
456 "peerDependenciesMeta": {
457 "bare-tcp": {
458 "optional": true
459 }
460 }
461 },
462 "node_modules/bare-logger": {
463 "version": "2.0.3",
464 "resolved": "https://registry.npmjs.org/bare-logger/-/bare-logger-2.0.3.tgz",
465 "integrity": "sha512-U6K7NxHdeAHxEgHFDV8zXvgjgDZKQO6LlCrxQvUvlrZEVTnoezSImMWizi85rbZpVsbeI1ZWcLCRGnoIf5EV8Q==",
466 "license": "Apache-2.0",
467 "dependencies": {
468 "bare-format": "^1.0.0"
469 }
470 },
471 "node_modules/bare-module": {
472 "version": "6.1.3",
473 "resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.1.3.tgz",
474 "integrity": "sha512-5XWsVHsvtWMH4tK4DQWgpNTV0t/sg3ZrAaQLIxrwjrS5+u8Q9vEgc/zQ4QaDPWDse/y/5h+d+YG1Q0JfSMt0zA==",
475 "license": "Apache-2.0",
476 "dependencies": {
477 "bare-bundle": "^1.3.0",
478 "bare-module-lexer": "^1.0.0",
479 "bare-module-resolve": "^1.8.0",
480 "bare-path": "^3.0.0",
481 "bare-url": "^2.0.1"
482 },
483 "engines": {
484 "bare": ">=1.23.0"
485 },
486 "peerDependencies": {
487 "bare-buffer": "*"
488 },
489 "peerDependenciesMeta": {
490 "bare-buffer": {
491 "optional": true
492 }
493 }
494 },
495 "node_modules/bare-module-lexer": {
496 "version": "1.4.7",
497 "resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.4.7.tgz",
498 "integrity": "sha512-0klU4eMsjh/wcxi8FdHmNom2j2F4kmkXOhyJFL9qTaSFp2lE3m6BtbKgMHY8R5miqC9r8/IfA8wzXnC5Os14WA==",
499 "license": "Apache-2.0",
500 "dependencies": {
501 "require-addon": "^1.0.2"
502 },
503 "peerDependencies": {
504 "bare-buffer": "*"
505 },
506 "peerDependenciesMeta": {
507 "bare-buffer": {
508 "optional": true
509 }
510 }
511 },
512 "node_modules/bare-module-resolve": {
513 "version": "1.12.1",
514 "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz",
515 "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==",
516 "license": "Apache-2.0",
517 "dependencies": {
518 "bare-semver": "^1.0.0"
519 },
520 "peerDependencies": {
521 "bare-url": "*"
522 },
523 "peerDependenciesMeta": {
524 "bare-url": {
525 "optional": true
526 }
527 }
528 },
529 "node_modules/bare-module-traverse": {
530 "version": "2.0.1",
531 "resolved": "https://registry.npmjs.org/bare-module-traverse/-/bare-module-traverse-2.0.1.tgz",
532 "integrity": "sha512-1au+Og5p97T9b6Y7xmHZ7KtpW8vEYtz2jC2whmm+YJp46EaHfk26j91MmQhufdkR/8sdK1Q5p+P9A/Y5GrJg7Q==",
533 "license": "Apache-2.0",
534 "dependencies": {
535 "bare-addon-resolve": "^1.5.0",
536 "bare-module-lexer": "^1.4.0",
537 "bare-module-resolve": "^1.7.0"
538 },
539 "peerDependencies": {
540 "bare-buffer": "*",
541 "bare-url": "*"
542 },
543 "peerDependenciesMeta": {
544 "bare-buffer": {
545 "optional": true
546 },
547 "bare-url": {
548 "optional": true
549 }
550 }
551 },
552 "node_modules/bare-net": {
553 "version": "2.3.1",
554 "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.1.tgz",
555 "integrity": "sha512-MypSqDKpDU2Xt7FIfazn5yGvRnV09gFcIPHGWstW0gxuzA4tucTcwJSZeos97C4F89vtU5oGwXDN/HrGN6Y4Jw==",
556 "license": "Apache-2.0",
557 "dependencies": {
558 "bare-events": "^2.2.2",
559 "bare-pipe": "^4.0.0",
560 "bare-stream": "^2.0.0",
561 "bare-tcp": "^2.0.0"
562 }
563 },
564 "node_modules/bare-node-runtime": {
565 "version": "1.2.0",
566 "resolved": "https://registry.npmjs.org/bare-node-runtime/-/bare-node-runtime-1.2.0.tgz",
567 "integrity": "sha512-oCpkcKBLDuzhtga0Q/63Ds3Gj03I0jIf+VX4mDhSClpoBfz/wnDtl2d1NKQDAVNCoP4I617GrJkS3vwNO4dd7w==",
568 "license": "Apache-2.0",
569 "dependencies": {
570 "bare-abort-controller": "^1.0.0",
571 "bare-assert": "^1.1.0",
572 "bare-async-hooks": "^0.0.0",
573 "bare-buffer": "^3.3.1",
574 "bare-console": "^6.0.1",
575 "bare-crypto": "^1.11.2",
576 "bare-dgram": "^1.0.1",
577 "bare-diagnostics-channel": "^1.1.0",
578 "bare-dns": "^2.1.4",
579 "bare-events": "^2.7.0",
580 "bare-fetch": "^2.5.0",
581 "bare-fs": "^4.2.3",
582 "bare-http1": "^4.0.4",
583 "bare-https": "^2.0.0",
584 "bare-inspector": "^5.0.0",
585 "bare-module": "^6.1.2",
586 "bare-net": "^2.0.2",
587 "bare-os": "^3.6.2",
588 "bare-path": "^3.0.0",
589 "bare-performance": "^1.1.0",
590 "bare-process": "^4.2.1",
591 "bare-punycode": "^0.0.0",
592 "bare-querystring": "^1.0.0",
593 "bare-readline": "^1.1.0",
594 "bare-repl": "^6.0.1",
595 "bare-stream": "^2.7.0",
596 "bare-string-decoder": "^1.0.0",
597 "bare-subprocess": "^5.1.1",
598 "bare-timers": "^3.0.3",
599 "bare-tls": "^2.1.3",
600 "bare-tty": "^5.0.3",
601 "bare-url": "^2.2.2",
602 "bare-utils": "^1.5.1",
603 "bare-v8": "^1.0.1",
604 "bare-vm": "^1.0.0",
605 "bare-worker": "^4.0.0",
606 "bare-ws": "^2.1.0",
607 "bare-zlib": "^1.3.1"
608 }
609 },
610 "node_modules/bare-os": {
611 "version": "3.8.0",
612 "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz",
613 "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==",
614 "license": "Apache-2.0",
615 "engines": {
616 "bare": ">=1.14.0"
617 }
618 },
619 "node_modules/bare-path": {
620 "version": "3.0.0",
621 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
622 "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
623 "license": "Apache-2.0",
624 "dependencies": {
625 "bare-os": "^3.0.1"
626 }
627 },
628 "node_modules/bare-performance": {
629 "version": "1.3.0",
630 "resolved": "https://registry.npmjs.org/bare-performance/-/bare-performance-1.3.0.tgz",
631 "integrity": "sha512-ITlHSQwsnJSPjpagTtPo043LRkFgcrl9SgPsVUBXZvTeGWuNLUfMRwmwAS+35i8qnIEinySoLxwhVoJNZAIF2g==",
632 "license": "Apache-2.0"
633 },
634 "node_modules/bare-pipe": {
635 "version": "4.1.5",
636 "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.5.tgz",
637 "integrity": "sha512-6OfxaG8JSkRh3Gc4hzHRsxNt+yu2PpN7lrv1V+T78GdknWQkVGwiEvu4m+1nbfk8cMVQ0TGxRvQ90XA4rhnTuw==",
638 "license": "Apache-2.0",
639 "dependencies": {
640 "bare-events": "^2.0.0",
641 "bare-stream": "^2.0.0"
642 },
643 "engines": {
644 "bare": ">=1.16.0"
645 }
646 },
647 "node_modules/bare-process": {
648 "version": "4.3.0",
649 "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.3.0.tgz",
650 "integrity": "sha512-HwtAdKbqS98V+jeWAagUdzkrJR4vtuPeeWlsoeD1YKTqM1hKUWM2eU+l2diUYeTqFfxY9b6J9SsBLuBukH0tMQ==",
651 "license": "Apache-2.0",
652 "dependencies": {
653 "bare-abort": "^2.0.13",
654 "bare-env": "^3.0.0",
655 "bare-events": "^2.3.1",
656 "bare-hrtime": "^2.0.0",
657 "bare-os": "^3.7.1",
658 "bare-pipe": "^4.0.0",
659 "bare-signals": "^4.0.0",
660 "bare-tty": "^5.0.0"
661 }
662 },
663 "node_modules/bare-punycode": {
664 "version": "0.0.0",
665 "resolved": "https://registry.npmjs.org/bare-punycode/-/bare-punycode-0.0.0.tgz",
666 "integrity": "sha512-PC2Y6mGLytZPJCB9M7CvO5Zb6uWYVUZ+5t3L6vePFuFM6tdC6SsQ+sIsuf0Sa6LHBoWURX7I9yMMbPXMv7TIdQ==",
667 "license": "Apache-2.0",
668 "dependencies": {
669 "punycode": "^2.3.1"
670 }
671 },
672 "node_modules/bare-querystring": {
673 "version": "1.0.0",
674 "resolved": "https://registry.npmjs.org/bare-querystring/-/bare-querystring-1.0.0.tgz",
675 "integrity": "sha512-Kbqdjd4f1MxMtutXzraEKIXqXRfq9uWLwzUT5bLUh9TCiPznY9l6vHc4BUPNIa13DyH1VAlPy3iOxHzhqV2quQ==",
676 "license": "Apache-2.0"
677 },
678 "node_modules/bare-readline": {
679 "version": "1.2.2",
680 "resolved": "https://registry.npmjs.org/bare-readline/-/bare-readline-1.2.2.tgz",
681 "integrity": "sha512-7MihQDnw80rxPyLJHZAUgFskFyGUj45FKJDIe57sW0iQyJIGroFA08mrtiMLMWUIgSd49hT7E97FCWvrc29p1Q==",
682 "license": "Apache-2.0",
683 "dependencies": {
684 "bare-ansi-escapes": "^2.0.0",
685 "bare-stream": "^2.0.0"
686 }
687 },
688 "node_modules/bare-realm": {
689 "version": "2.0.1",
690 "resolved": "https://registry.npmjs.org/bare-realm/-/bare-realm-2.0.1.tgz",
691 "integrity": "sha512-kQbYU6AAQu4XBTQesuz2+PpjBx7MJzf5Qw3nSLzQCzbVglx7Ml85MtWEjUe1AeslXqrd+nFPHMEbL55ouISscA==",
692 "license": "Apache-2.0",
693 "engines": {
694 "bare": ">=1.5.0"
695 }
696 },
697 "node_modules/bare-repl": {
698 "version": "6.0.2",
699 "resolved": "https://registry.npmjs.org/bare-repl/-/bare-repl-6.0.2.tgz",
700 "integrity": "sha512-w2R+5LOrMl9UH/gcFjvdIpsB+SgyLr8cW4uJo5bAEzRbRpVA+ZTm8pMHYdj0uqIUBClP/6fkyW0MnB1XkxakmQ==",
701 "license": "Apache-2.0",
702 "dependencies": {
703 "bare-inspect": "^3.0.0",
704 "bare-module": "^6.0.1",
705 "bare-os": "^3.0.1",
706 "bare-path": "^3.0.0",
707 "bare-pipe": "^4.0.0",
708 "bare-readline": "^1.0.0",
709 "bare-stream": "^2.0.0",
710 "bare-tty": "^5.0.0"
711 }
712 },
713 "node_modules/bare-semver": {
714 "version": "1.0.2",
715 "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.2.tgz",
716 "integrity": "sha512-ESVaN2nzWhcI5tf3Zzcq9aqCZ676VWzqw07eEZ0qxAcEOAFYBa0pWq8sK34OQeHLY3JsfKXZS9mDyzyxGjeLzA==",
717 "license": "Apache-2.0"
718 },
719 "node_modules/bare-signals": {
720 "version": "4.2.0",
721 "resolved": "https://registry.npmjs.org/bare-signals/-/bare-signals-4.2.0.tgz",
722 "integrity": "sha512-fNHMOdQIlYuTvMB3Oh9Apk99hLKn351+Ir8vz+khiPTcOqIyGG4uWWjdLTzxWdYGsA0eT+We3y0K74hjj2nq7A==",
723 "license": "Apache-2.0",
724 "dependencies": {
725 "bare-events": "^2.5.3",
726 "bare-os": "^3.3.1"
727 },
728 "engines": {
729 "bare": ">=1.7.0"
730 }
731 },
732 "node_modules/bare-stream": {
733 "version": "2.10.0",
734 "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.10.0.tgz",
735 "integrity": "sha512-DOPZF/DDcDruKDA43cOw6e9Quq5daua7ygcAwJE/pKJsRWhgSSemi7qVNGE5kyDIxIeN1533G/zfbvWX7Wcb9w==",
736 "license": "Apache-2.0",
737 "dependencies": {
738 "streamx": "^2.25.0",
739 "teex": "^1.0.1"
740 },
741 "peerDependencies": {
742 "bare-buffer": "*",
743 "bare-events": "*"
744 },
745 "peerDependenciesMeta": {
746 "bare-buffer": {
747 "optional": true
748 },
749 "bare-events": {
750 "optional": true
751 }
752 }
753 },
754 "node_modules/bare-string-decoder": {
755 "version": "1.0.0",
756 "resolved": "https://registry.npmjs.org/bare-string-decoder/-/bare-string-decoder-1.0.0.tgz",
757 "integrity": "sha512-FjFvfHo88U7borNQSj9ijP2JTb1asqY6K28OZrix4dF9iZf5D2wi1+99CgLlHT3HtYqdwxRhDAiKu8rVstt5rQ==",
758 "license": "Apache-2.0",
759 "dependencies": {
760 "text-decoder": "^1.2.3"
761 },
762 "peerDependencies": {
763 "bare-buffer": "*"
764 },
765 "peerDependenciesMeta": {
766 "bare-buffer": {
767 "optional": true
768 }
769 }
770 },
771 "node_modules/bare-structured-clone": {
772 "version": "1.5.3",
773 "resolved": "https://registry.npmjs.org/bare-structured-clone/-/bare-structured-clone-1.5.3.tgz",
774 "integrity": "sha512-vC/YqGsp67ZeFnpyAskwaEIXtNscnCwFVKlSk0Oh2X3AqWT6H+7DC9vVI080FohUJeuUuB9UNx4UFdDcyQgZaw==",
775 "license": "Apache-2.0",
776 "dependencies": {
777 "bare-type": "^1.1.0",
778 "compact-encoding": "^2.15.0"
779 },
780 "engines": {
781 "bare": ">=1.2.0"
782 },
783 "peerDependencies": {
784 "bare-buffer": "*",
785 "bare-url": "*"
786 },
787 "peerDependenciesMeta": {
788 "bare-buffer": {
789 "optional": true
790 },
791 "bare-url": {
792 "optional": true
793 }
794 }
795 },
796 "node_modules/bare-stylize": {
797 "version": "0.0.1",
798 "resolved": "https://registry.npmjs.org/bare-stylize/-/bare-stylize-0.0.1.tgz",
799 "integrity": "sha512-l3MjmIl476bWijYWf3RbE+osl4iuXSOMudzp0vAqzIK7gPgn/+G3oAxp8Oin9CFF911KBP0LO9kts8Ci8mGZaQ==",
800 "license": "Apache-2.0",
801 "dependencies": {
802 "bare-ansi-escapes": "^2.2.3",
803 "bare-process": "^4.2.1"
804 }
805 },
806 "node_modules/bare-subprocess": {
807 "version": "5.2.3",
808 "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-5.2.3.tgz",
809 "integrity": "sha512-07wwswlV7M3sC9IykbZRZ/jHAkrXFWVLqdBWGv1y0ojCimtRD9hGwxdHmR5FUFmDUZLNsBmTYJNQqgio5+A85Q==",
810 "license": "Apache-2.0",
811 "dependencies": {
812 "bare-env": "^3.0.0",
813 "bare-events": "^2.5.4",
814 "bare-os": "^3.0.1",
815 "bare-pipe": "^4.0.0",
816 "bare-url": "^2.2.2"
817 },
818 "engines": {
819 "bare": ">=1.7.0"
820 },
821 "peerDependencies": {
822 "bare-buffer": "*"
823 },
824 "peerDependenciesMeta": {
825 "bare-buffer": {
826 "optional": true
827 }
828 }
829 },
830 "node_modules/bare-system-logger": {
831 "version": "1.0.3",
832 "resolved": "https://registry.npmjs.org/bare-system-logger/-/bare-system-logger-1.0.3.tgz",
833 "integrity": "sha512-HXTXZyhIIS4ZDYun4J9zKNQZDfLgEkkK8wy9nrTFJE5xq8APsOhDxuEsxP0YcNwC23DW1jaNpqD51zDeXPzIdg==",
834 "license": "Apache-2.0",
835 "dependencies": {
836 "bare-logger": "^2.0.0"
837 }
838 },
839 "node_modules/bare-tcp": {
840 "version": "2.2.7",
841 "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.7.tgz",
842 "integrity": "sha512-rjpqNQ2cOCkNo3NeYA/W4GTK3DRkl8sDHO3uos+AEswUjLC8XXMQF8WrJCSjlIowCbS6NUVxKE92X5RGXjyefg==",
843 "license": "Apache-2.0",
844 "peer": true,
845 "dependencies": {
846 "bare-dns": "^2.0.4",
847 "bare-events": "^2.5.4",
848 "bare-stream": "^2.6.4"
849 },
850 "engines": {
851 "bare": ">=1.16.0"
852 }
853 },
854 "node_modules/bare-thread": {
855 "version": "1.1.6",
856 "resolved": "https://registry.npmjs.org/bare-thread/-/bare-thread-1.1.6.tgz",
857 "integrity": "sha512-t6+mun2bpC6UlxyjWxn0bQYp8hM25XXRo6/UuoXx1mvYFZ6dHpp9nRyI0WWQk+n2Z5zb/g9hiUv6U+g4N8MQ3w==",
858 "license": "Apache-2.0",
859 "dependencies": {
860 "bare-bundle": "^1.9.0",
861 "bare-module-resolve": "^1.11.2",
862 "bare-module-traverse": "^2.0.0"
863 }
864 },
865 "node_modules/bare-timers": {
866 "version": "3.2.1",
867 "resolved": "https://registry.npmjs.org/bare-timers/-/bare-timers-3.2.1.tgz",
868 "integrity": "sha512-O+9e6Jol/BoYsQUzAFXG0gWUW1GWryFMblGcNBfi3smPrD3rJIKQwpw2FJARl2j/1VHne4a8YaHPK1cxDKfiYQ==",
869 "license": "Apache-2.0",
870 "engines": {
871 "bare": ">=1.7.0"
872 },
873 "peerDependencies": {
874 "bare-abort-controller": "*"
875 },
876 "peerDependenciesMeta": {
877 "bare-abort-controller": {
878 "optional": true
879 }
880 }
881 },
882 "node_modules/bare-tls": {
883 "version": "2.2.1",
884 "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-2.2.1.tgz",
885 "integrity": "sha512-hZ+ZqwrUO4dyH7/6WYkYWjgAFNJKjzwEYJiDaMnMs+eRleBDjQ3CvNZawpkw0Ar9jnM9NZK6+f6GqjkZ2FLGmQ==",
886 "license": "Apache-2.0",
887 "dependencies": {
888 "bare-net": "^2.0.1",
889 "bare-stream": "^2.6.4"
890 },
891 "engines": {
892 "bare": ">=1.7.0"
893 }
894 },
895 "node_modules/bare-tty": {
896 "version": "5.0.3",
897 "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.0.3.tgz",
898 "integrity": "sha512-jW24RBWRZOMHuSEWC9mh8wUKO5WUNl0UWUTNPn3yZ8qkOqwa8vaV2dR3a+BvblONPk7V35wuvK9P8508+judAg==",
899 "license": "Apache-2.0",
900 "dependencies": {
901 "bare-events": "^2.2.0",
902 "bare-signals": "^4.0.0",
903 "bare-stream": "^2.0.0"
904 },
905 "engines": {
906 "bare": ">=1.16.0"
907 }
908 },
909 "node_modules/bare-type": {
910 "version": "1.1.0",
911 "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz",
912 "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==",
913 "license": "Apache-2.0",
914 "engines": {
915 "bare": ">=1.2.0"
916 }
917 },
918 "node_modules/bare-url": {
919 "version": "2.4.0",
920 "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz",
921 "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
922 "license": "Apache-2.0",
923 "dependencies": {
924 "bare-path": "^3.0.0"
925 }
926 },
927 "node_modules/bare-utils": {
928 "version": "1.6.0",
929 "resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.6.0.tgz",
930 "integrity": "sha512-WhQEIkkAxkSnW7u1QgrI0AfNm5JpMruETXeYsb5qnkBJ0TTfNKygZmsh6rkoHBANaV+C/7Jed7bJP9OmEHG7rQ==",
931 "license": "Apache-2.0",
932 "dependencies": {
933 "bare-debug-log": "^2.0.0",
934 "bare-encoding": "^1.0.0",
935 "bare-format": "^1.0.0",
936 "bare-inspect": "^3.0.0",
937 "bare-stylize": "^0.0.1",
938 "bare-type": "^1.0.6"
939 }
940 },
941 "node_modules/bare-v8": {
942 "version": "1.0.1",
943 "resolved": "https://registry.npmjs.org/bare-v8/-/bare-v8-1.0.1.tgz",
944 "integrity": "sha512-/cR5ZvFWQRdtTZ4tx0j7TKvTWce8UnnLqm88fwHtJmfM7HODIBVjQGDT7KkDLeD2d/eHP2pzB71Y8/QyiMMKrQ==",
945 "license": "Apache-2.0"
946 },
947 "node_modules/bare-vm": {
948 "version": "1.0.1",
949 "resolved": "https://registry.npmjs.org/bare-vm/-/bare-vm-1.0.1.tgz",
950 "integrity": "sha512-yLnbRvKt3AhRTmtfTIrYfdTHqGEfIJc+Fgb2DcHejE0HJ+p5adGxxPMvd3893Z7iXVYnalxukNARn4oJSZELHQ==",
951 "license": "Apache-2.0",
952 "dependencies": {
953 "bare-realm": "^2.0.0"
954 }
955 },
956 "node_modules/bare-worker": {
957 "version": "4.1.6",
958 "resolved": "https://registry.npmjs.org/bare-worker/-/bare-worker-4.1.6.tgz",
959 "integrity": "sha512-yvsektF7xNqlAyjJV+YYeBx+sGZ8Fhbo5Cjc/lpIdkM8Gd/e3MlaxTFg8NknkyvJ4oDJXqvp38Q+ZOn5Gyr+nQ==",
960 "license": "Apache-2.0",
961 "dependencies": {
962 "bare-channel": "^5.1.5",
963 "bare-events": "^2.2.1",
964 "bare-module": "^6.0.1",
965 "bare-thread": "^1.1.3"
966 }
967 },
968 "node_modules/bare-ws": {
969 "version": "2.1.0",
970 "resolved": "https://registry.npmjs.org/bare-ws/-/bare-ws-2.1.0.tgz",
971 "integrity": "sha512-2gEWlPK9iyBchACdIY6oQXgmDz3KLrChdwrPgmU3IVXOFTnxTqSUT27WE/+izd4QojHj/SsqDkQiD2HDvuTdAA==",
972 "license": "Apache-2.0",
973 "dependencies": {
974 "bare-crypto": "^1.2.0",
975 "bare-events": "^2.3.1",
976 "bare-http1": "^4.0.0",
977 "bare-https": "^2.0.0",
978 "bare-stream": "^2.1.2"
979 },
980 "peerDependencies": {
981 "bare-buffer": "*",
982 "bare-url": "*"
983 },
984 "peerDependenciesMeta": {
985 "bare-buffer": {
986 "optional": true
987 },
988 "bare-url": {
989 "optional": true
990 }
991 }
992 },
993 "node_modules/bare-zlib": {
994 "version": "1.3.1",
995 "resolved": "https://registry.npmjs.org/bare-zlib/-/bare-zlib-1.3.1.tgz",
996 "integrity": "sha512-VP93GFzhrTdWh9mXNocn7XsP/nF5JQluiiSsbTvsQ4yIYlhEHRMF9lQmZZDXwzK9PNYaVGUV1bdQuqp0Mj7MHw==",
997 "license": "Apache-2.0",
998 "dependencies": {
999 "bare-stream": "^2.0.0"
1000 }
1001 },
1002 "node_modules/big-sparse-array": {
1003 "version": "1.0.3",
1004 "resolved": "https://registry.npmjs.org/big-sparse-array/-/big-sparse-array-1.0.3.tgz",
1005 "integrity": "sha512-6RjV/3mSZORlMdpUaQ6rUSpG637cZm0//E54YYGtQg1c1O+AbZP8UTdJ/TchsDZcTVLmyWZcseBfp2HBeXUXOQ==",
1006 "license": "MIT"
1007 },
1008 "node_modules/bridge-envelope": {
1009 "resolved": "../bridge-envelope",
1010 "link": true
1011 },
1012 "node_modules/bridge-vault": {
1013 "resolved": "../bridge-vault",
1014 "link": true
1015 },
1016 "node_modules/codecs": {
1017 "version": "3.1.0",
1018 "resolved": "https://registry.npmjs.org/codecs/-/codecs-3.1.0.tgz",
1019 "integrity": "sha512-Dqx8NwvBvnMeuPQdVKy/XEF71igjR5apxBvCGeV0pP1tXadOiaLvDTXt7xh+/5wI1ASB195mXQGJbw3Ml4YDWQ==",
1020 "license": "MIT",
1021 "dependencies": {
1022 "b4a": "^1.6.3"
1023 }
1024 },
1025 "node_modules/compact-encoding": {
1026 "version": "2.19.1",
1027 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.1.tgz",
1028 "integrity": "sha512-4J4FHc5UugLv2YMss1tL8OBZdgR0vVkV4EuXoAqFS4NbK4gz0Qp+E+VGUnwvDqmMOw2ce/H8duXh3rreC0Qccg==",
1029 "license": "Apache-2.0",
1030 "dependencies": {
1031 "b4a": "^1.3.0"
1032 }
1033 },
1034 "node_modules/corestore": {
1035 "version": "7.9.2",
1036 "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.9.2.tgz",
1037 "integrity": "sha512-dyIktVSVLu0skZ1UM4yODbNbZ46dBpTHypzwlwET1btVuS3YIf66Zz8Tx8h3Xa8nbLO2USGqrYtvPYzSRj2wCA==",
1038 "license": "MIT",
1039 "dependencies": {
1040 "b4a": "^1.6.7",
1041 "hypercore": "^11.19.0",
1042 "hypercore-crypto": "^3.4.2",
1043 "hypercore-errors": "^1.4.0",
1044 "hypercore-id-encoding": "^1.3.0",
1045 "ready-resource": "^1.1.1",
1046 "sodium-universal": "^5.0.1",
1047 "which-runtime": "^1.2.1"
1048 }
1049 },
1050 "node_modules/debounceify": {
1051 "version": "1.1.0",
1052 "resolved": "https://registry.npmjs.org/debounceify/-/debounceify-1.1.0.tgz",
1053 "integrity": "sha512-eKuHDVfJVg+u/0nPy8P+fhnLgbyuTgVxuCRrS/R7EpDSMMkBDgSes41MJtSAY1F1hcqfHz3Zy/qpqHHIp/EhdA==",
1054 "license": "MIT"
1055 },
1056 "node_modules/device-file": {
1057 "version": "2.3.1",
1058 "resolved": "https://registry.npmjs.org/device-file/-/device-file-2.3.1.tgz",
1059 "integrity": "sha512-bmON44lwxJPle9N2OcH4tqM44pMGZKT8G6OkzXkz0urvqQ9LKkoQdTS6w0ztYfmLdUE27R4UUmx0+y2gEz4Jug==",
1060 "license": "Apache-2.0",
1061 "dependencies": {
1062 "b4a": "^1.6.7",
1063 "bare-fs": "^4.0.1",
1064 "bare-path": "^3.0.0",
1065 "fd-lock": "^2.1.0",
1066 "fs-native-extensions": "^1.4.0",
1067 "ready-resource": "^1.2.0"
1068 }
1069 },
1070 "node_modules/events-universal": {
1071 "version": "1.0.1",
1072 "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
1073 "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
1074 "license": "Apache-2.0",
1075 "dependencies": {
1076 "bare-events": "^2.7.0"
1077 }
1078 },
1079 "node_modules/fast-fifo": {
1080 "version": "1.3.2",
1081 "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
1082 "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
1083 "license": "MIT"
1084 },
1085 "node_modules/fd-lock": {
1086 "version": "2.1.1",
1087 "resolved": "https://registry.npmjs.org/fd-lock/-/fd-lock-2.1.1.tgz",
1088 "integrity": "sha512-H3VkcWFl39Rk0xBokDcUyBcVs6VrYTUo/DhDWMzJOF99wXWDAvmvq/GlLTS2NTehsznZhp3fXVyrtB2ldDXhwg==",
1089 "license": "Apache-2.0",
1090 "dependencies": {
1091 "bare-fs": "^4.5.0",
1092 "fs-native-extensions": "^1.4.4",
1093 "ready-resource": "^1.2.0",
1094 "resource-on-exit": "^1.0.0"
1095 }
1096 },
1097 "node_modules/flat-tree": {
1098 "version": "1.13.0",
1099 "resolved": "https://registry.npmjs.org/flat-tree/-/flat-tree-1.13.0.tgz",
1100 "integrity": "sha512-fT3HIuCPwHhFgJ20QYzDHgUG0zMmFg5cHvFiFo5h+QMSJ28TihsEVY0f8HGliuO+pOzmvjMx1odToeaEWkTnyQ==",
1101 "license": "MIT"
1102 },
1103 "node_modules/fs-native-extensions": {
1104 "version": "1.4.5",
1105 "resolved": "https://registry.npmjs.org/fs-native-extensions/-/fs-native-extensions-1.4.5.tgz",
1106 "integrity": "sha512-ekV0T//iDm4AvhOcuPaHpxub4DI7HvY5ucLJVDvi7T2J+NZkQ9S6MuvgP0yeQvoqNUaAGyLjVYb1905BF9bpmg==",
1107 "license": "Apache-2.0",
1108 "dependencies": {
1109 "require-addon": "^1.1.0",
1110 "which-runtime": "^1.2.0"
1111 }
1112 },
1113 "node_modules/generate-object-property": {
1114 "version": "2.0.0",
1115 "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-2.0.0.tgz",
1116 "integrity": "sha512-KwuURPyqn2Mz8DdV29pJwQu0Y7tcsbkULr82eeOcY/ZllFK6I9Wm8dsRByIu7CKWlFi9BdW1b3mcXMp/kQBQsw==",
1117 "license": "MIT",
1118 "dependencies": {
1119 "is-property": "^1.0.0"
1120 }
1121 },
1122 "node_modules/generate-string": {
1123 "version": "1.0.1",
1124 "resolved": "https://registry.npmjs.org/generate-string/-/generate-string-1.0.1.tgz",
1125 "integrity": "sha512-IfTY0dKZM43ACyGvXkbG7De7WY7MxTS5VO6Juhe8oJKpCmrYYXoqp/cJMskkpi0k9H8wuXq0H+eI898/BCqvXg==",
1126 "license": "MIT"
1127 },
1128 "node_modules/hyper-feed": {
1129 "resolved": "../hyper-feed",
1130 "link": true
1131 },
1132 "node_modules/hyperbee": {
1133 "version": "2.27.3",
1134 "resolved": "https://registry.npmjs.org/hyperbee/-/hyperbee-2.27.3.tgz",
1135 "integrity": "sha512-PXURH2U4juUZyJRKHTrY5z1zX851pmI1Q0jfv5F/hCIErDt/ND8jOZuxc3hfOLM9f0W3qJEDTMlV5AJBkVPy8w==",
1136 "license": "MIT",
1137 "dependencies": {
1138 "b4a": "^1.6.0",
1139 "codecs": "^3.0.0",
1140 "debounceify": "^1.0.0",
1141 "hypercore-errors": "^1.0.0",
1142 "mutexify": "^1.4.0",
1143 "protocol-buffers-encodings": "^1.2.0",
1144 "rache": "^1.0.0",
1145 "ready-resource": "^1.0.0",
1146 "resolve-reject-promise": "^1.1.0",
1147 "safety-catch": "^1.0.2",
1148 "streamx": "^2.12.4",
1149 "unslab": "^1.2.0"
1150 }
1151 },
1152 "node_modules/hypercore": {
1153 "version": "11.27.17",
1154 "resolved": "https://registry.npmjs.org/hypercore/-/hypercore-11.27.17.tgz",
1155 "integrity": "sha512-u4GBBDN2EmOk/vzGOQqDOFTN3UdOuSAIbWik6ZboMHVr21n5rSM2rK/FXwc28kW7JUBtbfNKY28Hi86rVFMzYw==",
1156 "license": "MIT",
1157 "dependencies": {
1158 "@hyperswarm/secret-stream": "^6.0.0",
1159 "b4a": "^1.1.0",
1160 "bare-events": "^2.2.0",
1161 "big-sparse-array": "^1.0.3",
1162 "compact-encoding": "^2.11.0",
1163 "fast-fifo": "^1.3.0",
1164 "flat-tree": "^1.9.0",
1165 "hypercore-crypto": "^3.2.1",
1166 "hypercore-errors": "^1.5.0",
1167 "hypercore-id-encoding": "^1.2.0",
1168 "hypercore-storage": "^2.0.0",
1169 "is-options": "^1.0.1",
1170 "nanoassert": "^2.0.0",
1171 "protomux": "^3.5.0",
1172 "quickbit-universal": "^2.2.0",
1173 "random-array-iterator": "^1.0.0",
1174 "safety-catch": "^1.0.1",
1175 "sodium-universal": "^5.0.1",
1176 "streamx": "^2.12.4",
1177 "unslab": "^1.3.0",
1178 "z32": "^1.0.0"
1179 }
1180 },
1181 "node_modules/hypercore-crypto": {
1182 "version": "3.6.1",
1183 "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.6.1.tgz",
1184 "integrity": "sha512-ltIz2uDwy9pO/ZGTvqcjzyBkvt6O4cVm4r/nNxh0GFs/RbQtqP/i4wCvLEdmU7ptgtnw7fI67WYD1aHPuv4OVA==",
1185 "license": "MIT",
1186 "dependencies": {
1187 "b4a": "^1.6.6",
1188 "compact-encoding": "^2.15.0",
1189 "sodium-universal": "^5.0.0"
1190 }
1191 },
1192 "node_modules/hypercore-errors": {
1193 "version": "1.5.0",
1194 "resolved": "https://registry.npmjs.org/hypercore-errors/-/hypercore-errors-1.5.0.tgz",
1195 "integrity": "sha512-5KQ/SuDxsvet+7qWA35Ay6zdD9WyAHQoyWHGcPUTbmJBd300gvNIJoi3oma7kp4TTCSzii6qYumNZe/s0j/saQ==",
1196 "license": "Apache-2.0",
1197 "dependencies": {
1198 "hypercore-id-encoding": "^1.3.0"
1199 }
1200 },
1201 "node_modules/hypercore-id-encoding": {
1202 "version": "1.3.0",
1203 "resolved": "https://registry.npmjs.org/hypercore-id-encoding/-/hypercore-id-encoding-1.3.0.tgz",
1204 "integrity": "sha512-W6sHdGo5h7LXEsoWfKf/KfuROZmZRQDlGqJF2EPHW+noCK66Vvr0+zE6cL0vqQi18s0kQPeN7Sq3QyR0Ytc2VQ==",
1205 "license": "Apache-2.0",
1206 "dependencies": {
1207 "b4a": "^1.5.3",
1208 "z32": "^1.0.0"
1209 }
1210 },
1211 "node_modules/hypercore-storage": {
1212 "version": "2.7.1",
1213 "resolved": "https://registry.npmjs.org/hypercore-storage/-/hypercore-storage-2.7.1.tgz",
1214 "integrity": "sha512-eT3s227nS17c8+HWjl6ge0l1lPk9NDtBzQH///tOSZjCG4gxk0QnjhlJVS5q1dcnNB54Gs75JKceP798IymvTQ==",
1215 "license": "Apache-2.0",
1216 "dependencies": {
1217 "b4a": "^1.6.7",
1218 "bare-fs": "^4.0.1",
1219 "bare-path": "^3.0.0",
1220 "compact-encoding": "^2.16.0",
1221 "device-file": "^2.1.2",
1222 "flat-tree": "^1.12.1",
1223 "hypercore-crypto": "^3.4.2",
1224 "hyperschema": "^1.7.0",
1225 "index-encoder": "^3.3.2",
1226 "resolve-reject-promise": "^1.0.0",
1227 "rocksdb-native": "^3.11.0",
1228 "scope-lock": "^1.2.4",
1229 "streamx": "^2.21.1"
1230 }
1231 },
1232 "node_modules/hyperschema": {
1233 "version": "1.20.1",
1234 "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.20.1.tgz",
1235 "integrity": "sha512-7gnvaTNUs8FqdrU3NNE6nzhOneGyDlUlpROu9YFlHA61fE8ppKKf2gLZjZjsx/enNt+VFN4ITxy8ijypfyWBLw==",
1236 "license": "Apache-2.0",
1237 "dependencies": {
1238 "bare-fs": "^4.0.1",
1239 "compact-encoding": "^2.19.0",
1240 "generate-object-property": "^2.0.0",
1241 "generate-string": "^1.0.1"
1242 }
1243 },
1244 "node_modules/index-encoder": {
1245 "version": "3.5.0",
1246 "resolved": "https://registry.npmjs.org/index-encoder/-/index-encoder-3.5.0.tgz",
1247 "integrity": "sha512-idZ1cxtZz2dRV6rUiaP9Xo99UjXbSzjcMacoQmxUMu/A7fEQcNPngvwDJYeWelQUS5XFlY71/or70lKn6XnwbQ==",
1248 "license": "Apache-2.0",
1249 "dependencies": {
1250 "b4a": "^1.6.4"
1251 }
1252 },
1253 "node_modules/is-options": {
1254 "version": "1.0.2",
1255 "resolved": "https://registry.npmjs.org/is-options/-/is-options-1.0.2.tgz",
1256 "integrity": "sha512-u+Ai74c8Q74aS8BuHwPdI1jptGOT1FQXgCq8/zv0xRuE+wRgSMEJLj8lVO8Zp9BeGb29BXY6AsNPinfqjkr7Fg==",
1257 "license": "MIT",
1258 "dependencies": {
1259 "b4a": "^1.1.1"
1260 }
1261 },
1262 "node_modules/is-property": {
1263 "version": "1.0.2",
1264 "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
1265 "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
1266 "license": "MIT"
1267 },
1268 "node_modules/mutexify": {
1269 "version": "1.4.0",
1270 "resolved": "https://registry.npmjs.org/mutexify/-/mutexify-1.4.0.tgz",
1271 "integrity": "sha512-pbYSsOrSB/AKN5h/WzzLRMFgZhClWccf2XIB4RSMC8JbquiB0e0/SH5AIfdQMdyHmYtv4seU7yV/TvAwPLJ1Yg==",
1272 "license": "MIT",
1273 "dependencies": {
1274 "queue-tick": "^1.0.0"
1275 }
1276 },
1277 "node_modules/nanoassert": {
1278 "version": "2.0.0",
1279 "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-2.0.0.tgz",
1280 "integrity": "sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==",
1281 "license": "ISC"
1282 },
1283 "node_modules/noise-curve-ed": {
1284 "version": "2.1.0",
1285 "resolved": "https://registry.npmjs.org/noise-curve-ed/-/noise-curve-ed-2.1.0.tgz",
1286 "integrity": "sha512-zAzJx+VwZM3w6EA1hTmDhJfvAnCeBQn/1FAeZ0LtGxCcCtlAK/uJXQVF/eDVUOaAZ286lHlx77WJ+qj9SmsRRg==",
1287 "license": "ISC",
1288 "dependencies": {
1289 "b4a": "^1.1.0",
1290 "nanoassert": "^2.0.0",
1291 "sodium-universal": "^5.0.0"
1292 }
1293 },
1294 "node_modules/noise-handshake": {
1295 "version": "4.2.0",
1296 "resolved": "https://registry.npmjs.org/noise-handshake/-/noise-handshake-4.2.0.tgz",
1297 "integrity": "sha512-9O/VTNX/E2/AToyMTTDU0J/4WhaXMTdqc2DHs9vf+snoZ0cenSBq0dNYTVV1snYYEkmo6QeRrYMxtqtoYnY+LA==",
1298 "license": "Apache-2.0",
1299 "dependencies": {
1300 "b4a": "^1.1.0",
1301 "nanoassert": "^2.0.0",
1302 "sodium-universal": "^5.0.0"
1303 }
1304 },
1305 "node_modules/protocol-buffers-encodings": {
1306 "version": "1.2.0",
1307 "resolved": "https://registry.npmjs.org/protocol-buffers-encodings/-/protocol-buffers-encodings-1.2.0.tgz",
1308 "integrity": "sha512-daeNPuKh1NlLD1uDfbLpD+xyUTc07nEtfHwmBZmt/vH0B7VOM+JOCOpDcx9ZRpqHjAiIkGqyTDi+wfGSl17R9w==",
1309 "license": "MIT",
1310 "dependencies": {
1311 "b4a": "^1.6.0",
1312 "signed-varint": "^2.0.1",
1313 "varint": "5.0.0"
1314 }
1315 },
1316 "node_modules/protomux": {
1317 "version": "3.10.1",
1318 "resolved": "https://registry.npmjs.org/protomux/-/protomux-3.10.1.tgz",
1319 "integrity": "sha512-jgBqx8ZyaBWea/DFG4eOu1scOaeBwcnagiRC1XFVrjeGt7oAb0Pk5udPpBUpJ4DJBRjra50jD6YcZiQQTRqaaA==",
1320 "license": "MIT",
1321 "dependencies": {
1322 "b4a": "^1.3.1",
1323 "compact-encoding": "^2.5.1",
1324 "queue-tick": "^1.0.0",
1325 "safety-catch": "^1.0.1",
1326 "unslab": "^1.3.0"
1327 }
1328 },
1329 "node_modules/punycode": {
1330 "version": "2.3.1",
1331 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
1332 "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
1333 "license": "MIT",
1334 "engines": {
1335 "node": ">=6"
1336 }
1337 },
1338 "node_modules/queue-tick": {
1339 "version": "1.0.1",
1340 "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
1341 "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
1342 "license": "MIT"
1343 },
1344 "node_modules/quickbit-native": {
1345 "version": "2.4.8",
1346 "resolved": "https://registry.npmjs.org/quickbit-native/-/quickbit-native-2.4.8.tgz",
1347 "integrity": "sha512-FcCcqI+nIAWGknqhtrYT5TSD7t/N+Xd8ctM+2PrIIBuwOi5hx0SxAvuPtzLIEMfT/2h9+fhBakUe2uALOHX6yw==",
1348 "license": "Apache-2.0",
1349 "optional": true,
1350 "dependencies": {
1351 "require-addon": "^1.1.0"
1352 }
1353 },
1354 "node_modules/quickbit-universal": {
1355 "version": "2.2.0",
1356 "resolved": "https://registry.npmjs.org/quickbit-universal/-/quickbit-universal-2.2.0.tgz",
1357 "integrity": "sha512-w02i1R8n7+6pEKTud8DfF8zbFY9o7RtPlUc3jWbtCkDKvhbx/AvV7oNnz4/TcmsPGpSJS+fq5Ud6RH6+YPvSGg==",
1358 "license": "ISC",
1359 "dependencies": {
1360 "b4a": "^1.6.0",
1361 "simdle-universal": "^1.1.0"
1362 },
1363 "optionalDependencies": {
1364 "quickbit-native": "^2.2.0"
1365 }
1366 },
1367 "node_modules/rache": {
1368 "version": "1.0.0",
1369 "resolved": "https://registry.npmjs.org/rache/-/rache-1.0.0.tgz",
1370 "integrity": "sha512-e0k0g0w/8jOCB+7YqCIlOa+OJ38k0wrYS4x18pMSmqOvLKoyhmMhmQyCcvfY6VaP8D75cqkEnlakXs+RYYLqNg==",
1371 "license": "Apache-2.0"
1372 },
1373 "node_modules/random-array-iterator": {
1374 "version": "1.0.0",
1375 "resolved": "https://registry.npmjs.org/random-array-iterator/-/random-array-iterator-1.0.0.tgz",
1376 "integrity": "sha512-u7xCM93XqKEvPTP6xZp2ehttcAemKnh73oKNf1FvzuVCfpt6dILDt1Kxl1LeBjm2iNIeR49VGFhy4Iz3yOun+Q==",
1377 "license": "MIT"
1378 },
1379 "node_modules/ready-resource": {
1380 "version": "1.2.0",
1381 "resolved": "https://registry.npmjs.org/ready-resource/-/ready-resource-1.2.0.tgz",
1382 "integrity": "sha512-nfcco/8iAFV0M+2PYnmIc+/xY0iRb35d42HFHQ7AfjulbGEAFa+XWpByfwSyeVeiBoMLLFVMv1HixxNCqzSQ1g==",
1383 "license": "MIT",
1384 "dependencies": {
1385 "bare-events": "^2.2.0"
1386 }
1387 },
1388 "node_modules/refcounter": {
1389 "version": "1.0.0",
1390 "resolved": "https://registry.npmjs.org/refcounter/-/refcounter-1.0.0.tgz",
1391 "integrity": "sha512-1WosVzUy0kPUaPMEtlNDwm99UsteALIhXXR8rerELoa63WkYIXAl0hxgwPFrIYBRWZPGUyekQ04FRtPJ7dHk9w==",
1392 "license": "Apache-2.0"
1393 },
1394 "node_modules/require-addon": {
1395 "version": "1.2.0",
1396 "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
1397 "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==",
1398 "license": "Apache-2.0",
1399 "dependencies": {
1400 "bare-addon-resolve": "^1.3.0"
1401 },
1402 "engines": {
1403 "bare": ">=1.10.0"
1404 }
1405 },
1406 "node_modules/resolve-reject-promise": {
1407 "version": "1.1.0",
1408 "resolved": "https://registry.npmjs.org/resolve-reject-promise/-/resolve-reject-promise-1.1.0.tgz",
1409 "integrity": "sha512-LWsTOA91AqzBTjSGgX79Tc130pwcBK6xjpJEO+qRT5IKZ6bGnHKcc8QL3upUBcWuU8OTIDzKK2VNSwmmlqvAVg==",
1410 "license": "MIT"
1411 },
1412 "node_modules/resource-on-exit": {
1413 "version": "1.0.0",
1414 "resolved": "https://registry.npmjs.org/resource-on-exit/-/resource-on-exit-1.0.0.tgz",
1415 "integrity": "sha512-ViJwJAknCkLRJRPR+9SISQQ7R5eRgtdIHLJsM2hHx1MweAJbJxJ5XnMjjq0Lc7ZGv44ufzAqds1nKxiVkdy4ag==",
1416 "license": "Apache-2.0"
1417 },
1418 "node_modules/rocksdb-native": {
1419 "version": "3.15.0",
1420 "resolved": "https://registry.npmjs.org/rocksdb-native/-/rocksdb-native-3.15.0.tgz",
1421 "integrity": "sha512-czYx0hOzKAy+HYmJROZm7thvJ1tHFWHNkGI3nomumXWzrJghJdrWo4MIhehGX4OTff0lnOCoB2zu6ekzIKY0rQ==",
1422 "license": "Apache-2.0",
1423 "dependencies": {
1424 "compact-encoding": "^2.15.0",
1425 "ready-resource": "^1.0.0",
1426 "refcounter": "^1.0.0",
1427 "require-addon": "^1.0.2",
1428 "resolve-reject-promise": "^1.1.0",
1429 "signal-promise": "^1.0.3",
1430 "streamx": "^2.16.1"
1431 },
1432 "engines": {
1433 "bare": ">=1.16.0"
1434 }
1435 },
1436 "node_modules/safety-catch": {
1437 "version": "1.0.3",
1438 "resolved": "https://registry.npmjs.org/safety-catch/-/safety-catch-1.0.3.tgz",
1439 "integrity": "sha512-Zq+J1TefpoEq/HTUabo0YXX5MNvttjWYODGohgPBO2jfko8Wqx3JYMgE823szDFVamdH5PlpByvfiWScTdSYDA==",
1440 "license": "MIT"
1441 },
1442 "node_modules/scope-lock": {
1443 "version": "1.2.4",
1444 "resolved": "https://registry.npmjs.org/scope-lock/-/scope-lock-1.2.4.tgz",
1445 "integrity": "sha512-BpSd8VCuCxW9ZitcdIC/vjs3gMaP9bRBL5nkHcyfX2VrS52n13/rHuBA2xJ/S/4DPuRdAO/Bk8pWd8eD/gHCIA==",
1446 "license": "Apache-2.0"
1447 },
1448 "node_modules/signal-promise": {
1449 "version": "1.0.3",
1450 "resolved": "https://registry.npmjs.org/signal-promise/-/signal-promise-1.0.3.tgz",
1451 "integrity": "sha512-WBgv0UnIq2C+Aeh0/n+IRpP6967eIx9WpynTUoiW3isPpfe1zu2LJzyfXdo9Tgef8yR/sGjcMvoUXD7EYdiz+g==",
1452 "license": "MIT"
1453 },
1454 "node_modules/signed-varint": {
1455 "version": "2.0.1",
1456 "resolved": "https://registry.npmjs.org/signed-varint/-/signed-varint-2.0.1.tgz",
1457 "integrity": "sha512-abgDPg1106vuZZOvw7cFwdCABddfJRz5akcCcchzTbhyhYnsG31y4AlZEgp315T7W3nQq5P4xeOm186ZiPVFzw==",
1458 "license": "MIT",
1459 "dependencies": {
1460 "varint": "~5.0.0"
1461 }
1462 },
1463 "node_modules/simdle-native": {
1464 "version": "1.3.9",
1465 "resolved": "https://registry.npmjs.org/simdle-native/-/simdle-native-1.3.9.tgz",
1466 "integrity": "sha512-Isc8sP4OiiIU0mpslD4GHEnR0VQWvR/54WN7YtwEDkNdTJVWtpmvsSvsgRlw5BNGxdYXlVRegdnrSu10H/PhvA==",
1467 "license": "Apache-2.0",
1468 "optional": true,
1469 "dependencies": {
1470 "b4a": "^1.6.0",
1471 "require-addon": "^1.1.0"
1472 }
1473 },
1474 "node_modules/simdle-universal": {
1475 "version": "1.1.2",
1476 "resolved": "https://registry.npmjs.org/simdle-universal/-/simdle-universal-1.1.2.tgz",
1477 "integrity": "sha512-3n3w1bs+uwgHKQjt6arez83EywNlhZzYvNOhvAASTl/8KqNIcqr6aHyGt3JRlfuUC7iB0tomJRPlJ2cRGIpBzA==",
1478 "license": "ISC",
1479 "dependencies": {
1480 "b4a": "^1.6.0"
1481 },
1482 "optionalDependencies": {
1483 "simdle-native": "^1.1.1"
1484 }
1485 },
1486 "node_modules/sodium-native": {
1487 "version": "5.1.0",
1488 "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz",
1489 "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==",
1490 "license": "MIT",
1491 "dependencies": {
1492 "bare-assert": "^1.2.0",
1493 "require-addon": "^1.1.0",
1494 "which-runtime": "^1.2.1"
1495 },
1496 "engines": {
1497 "bare": ">=1.16.0"
1498 }
1499 },
1500 "node_modules/sodium-secretstream": {
1501 "version": "1.2.0",
1502 "resolved": "https://registry.npmjs.org/sodium-secretstream/-/sodium-secretstream-1.2.0.tgz",
1503 "integrity": "sha512-q/DbraNFXm1KfCiiZvapmz5UC3OlpirYFIvBK2MhGaOFSb3gRyk8OXTi17UI9SGfshQNCpsVvlopogbzZNyW6Q==",
1504 "license": "MIT",
1505 "dependencies": {
1506 "b4a": "^1.1.1",
1507 "sodium-universal": "^5.0.0"
1508 }
1509 },
1510 "node_modules/sodium-universal": {
1511 "version": "5.0.1",
1512 "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz",
1513 "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==",
1514 "license": "MIT",
1515 "dependencies": {
1516 "sodium-native": "^5.0.1"
1517 },
1518 "peerDependencies": {
1519 "sodium-javascript": "~0.8.0"
1520 },
1521 "peerDependenciesMeta": {
1522 "sodium-javascript": {
1523 "optional": true
1524 }
1525 }
1526 },
1527 "node_modules/streamx": {
1528 "version": "2.25.0",
1529 "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
1530 "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
1531 "license": "MIT",
1532 "dependencies": {
1533 "events-universal": "^1.0.0",
1534 "fast-fifo": "^1.3.2",
1535 "text-decoder": "^1.1.0"
1536 }
1537 },
1538 "node_modules/teex": {
1539 "version": "1.0.1",
1540 "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
1541 "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
1542 "license": "MIT",
1543 "dependencies": {
1544 "streamx": "^2.12.5"
1545 }
1546 },
1547 "node_modules/text-decoder": {
1548 "version": "1.2.7",
1549 "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
1550 "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
1551 "license": "Apache-2.0",
1552 "dependencies": {
1553 "b4a": "^1.6.4"
1554 }
1555 },
1556 "node_modules/timeout-refresh": {
1557 "version": "2.0.1",
1558 "resolved": "https://registry.npmjs.org/timeout-refresh/-/timeout-refresh-2.0.1.tgz",
1559 "integrity": "sha512-SVqEcMZBsZF9mA78rjzCrYrUs37LMJk3ShZ851ygZYW1cMeIjs9mL57KO6Iv5mmjSQnOe/29/VAfGXo+oRCiVw==",
1560 "license": "MIT"
1561 },
1562 "node_modules/udx-native": {
1563 "version": "1.19.2",
1564 "resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.19.2.tgz",
1565 "integrity": "sha512-RNYh+UhfryCsF5hE2ZOuIqcZ+qdipXK3UsarwxWJwsUQZFE3ybwz0mPjwb5ev1PMBcjFahWiepS/q0wwL51c2g==",
1566 "license": "Apache-2.0",
1567 "dependencies": {
1568 "b4a": "^1.5.0",
1569 "bare-events": "^2.2.0",
1570 "require-addon": "^1.1.0",
1571 "streamx": "^2.22.0"
1572 },
1573 "engines": {
1574 "bare": ">=1.17.4"
1575 }
1576 },
1577 "node_modules/unslab": {
1578 "version": "1.3.0",
1579 "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz",
1580 "integrity": "sha512-YATkfKAFj47kTzmiQrWXMyRvaVrHsW6MEALa4bm+FhiA2YG4oira+Z3DXN6LrYOYn2Y8eO94Lwl9DOHjs1FpoQ==",
1581 "license": "Apache-2.0",
1582 "dependencies": {
1583 "b4a": "^1.6.6"
1584 }
1585 },
1586 "node_modules/varint": {
1587 "version": "5.0.0",
1588 "resolved": "https://registry.npmjs.org/varint/-/varint-5.0.0.tgz",
1589 "integrity": "sha512-gC13b/bWrqQoKY2EmROCZ+AR0jitc6DnDGaQ6Ls9QpKmuSgJB1eQ7H3KETtQm7qSdMWMKCmsshyCmUwMLh3OAA==",
1590 "license": "MIT"
1591 },
1592 "node_modules/which-runtime": {
1593 "version": "1.3.2",
1594 "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.3.2.tgz",
1595 "integrity": "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw==",
1596 "license": "Apache-2.0"
1597 },
1598 "node_modules/z32": {
1599 "version": "1.1.0",
1600 "resolved": "https://registry.npmjs.org/z32/-/z32-1.1.0.tgz",
1601 "integrity": "sha512-1WUHy+VS6d0HPNspDxvLssBbeQjXMjSnpv0vH82vRAUfg847NmX3OXozp/hRP5jPhxBbrVzrgvAt+UsGNzRFQQ==",
1602 "license": "MIT",
1603 "dependencies": {
1604 "b4a": "^1.5.3"
1605 }
1606 }
1607 }
1608}
hyper-social-web/package.json +28 -0
……@@ -0,0 +1,28 @@
1{
2 "name": "hyper-social-web",
3 "version": "0.1.0",
4 "description": "POSSE social network client — unified timeline and cross-posting for Mastodon, Bluesky, and Instagram",
5 "type": "module",
6 "main": "index.js",
7 "scripts": {
8 "start": "bare index.js serve",
9 "test": "brittle test/*.test.js"
10 },
11 "dependencies": {
12 "b4a": "^1.8.0",
13 "bare-crypto": "^1.13.4",
14 "bare-ffmpeg": "^1.2.1",
15 "bare-fs": "^4.5.6",
16 "bare-http1": "^4.5.5",
17 "bare-https": "^2.1.3",
18 "bare-node-runtime": "^1.2.0",
19 "bare-os": "^3.8.0",
20 "bare-path": "^3.0.0",
21 "bare-subprocess": "^5.2.3",
22 "bridge-envelope": "file:../bridge-envelope",
23 "bridge-vault": "file:../bridge-vault",
24 "corestore": "^7.9.2",
25 "hyper-feed": "file:../hyper-feed",
26 "hyperbee": "^2.27.3"
27 }
28}
hyper-social-web/web/css/app.css +205 -0
……@@ -0,0 +1,205 @@
1* { margin: 0; padding: 0; box-sizing: border-box; }
2
3:root {
4 --bg: #0d1117;
5 --bg-card: #161b22;
6 --bg-input: #0d1117;
7 --border: #30363d;
8 --text: #e6edf3;
9 --text-muted: #8b949e;
10 --accent: #58a6ff;
11 --accent-hover: #79c0ff;
12 --green: #3fb950;
13 --red: #f85149;
14 --mastodon: #6364ff;
15 --atproto: #0085ff;
16 --instagram: #e1306c;
17 --local: #3fb950;
18 --radius: 8px;
19}
20
21body {
22 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
23 background: var(--bg);
24 color: var(--text);
25 line-height: 1.5;
26}
27
28#app {
29 max-width: 640px;
30 margin: 0 auto;
31 padding: 0 16px;
32}
33
34/* Header */
35header {
36 display: flex;
37 align-items: center;
38 justify-content: space-between;
39 padding: 16px 0;
40 border-bottom: 1px solid var(--border);
41 margin-bottom: 16px;
42}
43
44header h1 { font-size: 18px; font-weight: 600; }
45header h1 a { color: var(--text); text-decoration: none; }
46
47nav { display: flex; gap: 8px; }
48nav a {
49 color: var(--text-muted);
50 text-decoration: none;
51 padding: 6px 12px;
52 border-radius: var(--radius);
53 font-size: 14px;
54}
55nav a:hover { color: var(--text); background: var(--bg-card); }
56nav a.active { color: var(--accent); background: var(--bg-card); }
57
58/* Buttons */
59.btn {
60 background: var(--bg-card);
61 color: var(--text);
62 border: 1px solid var(--border);
63 padding: 8px 16px;
64 border-radius: var(--radius);
65 cursor: pointer;
66 font-size: 14px;
67}
68.btn:hover { border-color: var(--accent); }
69.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
70.btn-primary:hover { background: var(--accent-hover); }
71.btn-small { padding: 4px 10px; font-size: 12px; }
72.btn-block { display: block; width: 100%; margin-top: 12px; }
73.btn-danger { border-color: var(--red); color: var(--red); }
74
75/* Filters */
76.filters { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
77.filter-btn {
78 background: var(--bg-card);
79 color: var(--text-muted);
80 border: 1px solid var(--border);
81 padding: 4px 12px;
82 border-radius: 20px;
83 cursor: pointer;
84 font-size: 13px;
85}
86.filter-btn:hover { color: var(--text); border-color: var(--text-muted); }
87.filter-btn.active { color: var(--accent); border-color: var(--accent); }
88
89/* Feed */
90.feed { display: flex; flex-direction: column; gap: 2px; }
91
92.post {
93 background: var(--bg-card);
94 border: 1px solid var(--border);
95 border-radius: var(--radius);
96 padding: 14px;
97}
98.post-header {
99 display: flex;
100 align-items: center;
101 gap: 8px;
102 margin-bottom: 6px;
103}
104.post-avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
105.post-avatar-placeholder { width: 40px; height: 40px; border-radius: 50%; background: var(--border); flex-shrink: 0; }
106.post-author-info { display: flex; flex-direction: column; gap: 0; min-width: 0; }
107.post-displayname { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
108.post-author { font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
109.post-platform {
110 font-size: 11px;
111 padding: 2px 6px;
112 border-radius: 4px;
113 font-weight: 600;
114 text-transform: uppercase;
115}
116.post-platform.mastodon { background: var(--mastodon); color: #fff; }
117.post-platform.atproto { background: var(--atproto); color: #fff; }
118.post-platform.instagram { background: var(--instagram); color: #fff; }
119.post-platform.local { background: var(--local); color: #000; }
120.post-time { color: var(--text-muted); font-size: 12px; margin-left: auto; }
121.post-text { font-size: 14px; white-space: pre-wrap; word-wrap: break-word; }
122.post-media { margin-top: 8px; }
123.post-media img { max-width: 100%; border-radius: var(--radius); }
124.post-meta { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-muted); }
125.post-syndicated { margin-top: 6px; font-size: 11px; color: var(--text-muted); }
126
127.post-type-boost { border-left: 3px solid var(--text-muted); }
128.post-type-reply { border-left: 3px solid var(--accent); }
129
130/* Compose */
131.compose-box { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
132.compose-box textarea {
133 width: 100%;
134 background: var(--bg-input);
135 color: var(--text);
136 border: 1px solid var(--border);
137 border-radius: var(--radius);
138 padding: 10px;
139 font-size: 14px;
140 font-family: inherit;
141 resize: vertical;
142}
143.compose-box textarea:focus { outline: none; border-color: var(--accent); }
144.compose-media { margin: 10px 0; }
145#media-preview { display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); }
146#preview-name { font-size: 13px; color: var(--text-muted); flex: 1; }
147#alt-text { width: 100%; background: var(--bg-input); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 8px; font-size: 13px; }
148#alt-text:focus { outline: none; border-color: var(--accent); }
149.compose-targets { margin: 12px 0; display: flex; gap: 16px; }
150.compose-targets label { font-size: 14px; display: flex; align-items: center; gap: 4px; }
151.compose-actions { display: flex; justify-content: space-between; align-items: center; }
152#char-count { font-size: 13px; color: var(--text-muted); }
153
154/* Settings */
155.connect-form { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
156.connect-form select, .connect-form input {
157 display: block;
158 width: 100%;
159 background: var(--bg-input);
160 color: var(--text);
161 border: 1px solid var(--border);
162 border-radius: var(--radius);
163 padding: 8px 10px;
164 font-size: 14px;
165 margin-bottom: 8px;
166}
167.connect-form select:focus, .connect-form input:focus { outline: none; border-color: var(--accent); }
168.connect-form .btn { margin-top: 8px; }
169
170.account-card {
171 background: var(--bg-card);
172 border: 1px solid var(--border);
173 border-radius: var(--radius);
174 padding: 12px;
175 margin-bottom: 8px;
176 display: flex;
177 align-items: center;
178 justify-content: space-between;
179}
180.account-info { display: flex; align-items: center; gap: 8px; }
181
182/* Notifications */
183.notification {
184 background: var(--bg-card);
185 border: 1px solid var(--border);
186 border-radius: var(--radius);
187 padding: 10px 14px;
188 font-size: 14px;
189}
190.notification .notif-type { font-weight: 600; text-transform: capitalize; }
191
192/* Result messages */
193.result { margin-top: 10px; padding: 8px 12px; border-radius: var(--radius); font-size: 13px; }
194.result.success { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid rgba(63,185,80,0.3); }
195.result.error { background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid rgba(248,81,73,0.3); }
196
197h2 { font-size: 18px; margin-bottom: 12px; }
198h3 { font-size: 15px; margin: 16px 0 8px; color: var(--text-muted); }
199
200/* Responsive */
201@media (max-width: 480px) {
202 header { flex-direction: column; gap: 8px; }
203 nav { flex-wrap: wrap; justify-content: center; }
204 .compose-targets { flex-direction: column; gap: 8px; }
205}
hyper-social-web/web/index.html +105 -0
……@@ -0,0 +1,105 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>hyper-social</title>
7 <link rel="stylesheet" href="css/app.css">
8</head>
9<body>
10 <div id="app">
11 <header>
12 <h1><a href="#/">hyper-social</a></h1>
13 <nav>
14 <a href="#/" class="nav-link active" data-page="timeline">Timeline</a>
15 <a href="#/compose" class="nav-link" data-page="compose">Compose</a>
16 <a href="#/notifications" class="nav-link" data-page="notifications">Notifications</a>
17 <a href="#/settings" class="nav-link" data-page="settings">Settings</a>
18 </nav>
19 </header>
20
21 <main>
22 <!-- Timeline filters -->
23 <div id="page-timeline" class="page">
24 <div class="filters">
25 <button class="filter-btn active" data-source="">All</button>
26 <button class="filter-btn" data-source="mastodon">Mastodon</button>
27 <button class="filter-btn" data-source="atproto">Bluesky</button>
28 <button class="filter-btn" data-source="instagram">Instagram</button>
29 <button class="filter-btn" data-source="local">My Posts</button>
30 </div>
31 <button id="refresh-btn" class="btn btn-small">Refresh</button>
32 <div id="timeline" class="feed"></div>
33 <button id="load-more" class="btn btn-block" style="display:none">Load more</button>
34 </div>
35
36 <!-- Compose (POSSE) -->
37 <div id="page-compose" class="page" style="display:none">
38 <h2>Compose</h2>
39 <div class="compose-box">
40 <textarea id="compose-text" placeholder="What's on your mind?" rows="4"></textarea>
41 <div class="compose-media">
42 <input type="file" id="media-input" accept="image/*,video/*" style="display:none">
43 <button id="attach-btn" class="btn btn-small">Attach image/video</button>
44 <div id="media-preview" style="display:none">
45 <img id="preview-img" style="max-width:200px;max-height:150px;border-radius:6px">
46 <span id="preview-name"></span>
47 <button id="remove-media" class="btn btn-small btn-danger">Remove</button>
48 </div>
49 <input type="text" id="alt-text" placeholder="Alt text (accessibility)" style="display:none;margin-top:6px">
50 </div>
51 <div class="compose-targets">
52 <label><input type="checkbox" name="target" value="mastodon" checked> Mastodon</label>
53 <label><input type="checkbox" name="target" value="atproto" checked> Bluesky</label>
54 <label><input type="checkbox" name="target" value="instagram" disabled> Instagram</label>
55 </div>
56 <div class="compose-actions">
57 <span id="char-count">0</span>
58 <button id="publish-btn" class="btn btn-primary">Publish</button>
59 </div>
60 <div id="publish-result" class="result" style="display:none"></div>
61 </div>
62 </div>
63
64 <!-- Notifications -->
65 <div id="page-notifications" class="page" style="display:none">
66 <h2>Notifications</h2>
67 <div id="notifications" class="feed"></div>
68 </div>
69
70 <!-- Settings -->
71 <div id="page-settings" class="page" style="display:none">
72 <h2>Connected Accounts</h2>
73 <div id="accounts-list"></div>
74
75 <h3>Add Account</h3>
76 <div class="connect-form">
77 <select id="connect-platform">
78 <option value="mastodon">Mastodon</option>
79 <option value="atproto">Bluesky</option>
80 </select>
81
82 <div id="mastodon-fields" class="platform-fields">
83 <input type="text" id="masto-instance" placeholder="Instance (e.g., mastodon.social)">
84 <input type="password" id="masto-token" placeholder="Access token">
85 </div>
86
87 <div id="atproto-fields" class="platform-fields" style="display:none">
88 <input type="text" id="bsky-handle" placeholder="Handle (e.g., you.bsky.social)">
89 <input type="password" id="bsky-password" placeholder="App password">
90 <input type="text" id="bsky-service" placeholder="Service (default: https://bsky.social)" value="https://bsky.social">
91 </div>
92
93 <button id="connect-btn" class="btn btn-primary">Connect</button>
94 <div id="connect-result" class="result" style="display:none"></div>
95 </div>
96 </div>
97 </main>
98 </div>
99
100 <script src="js/api.js"></script>
101 <script src="js/timeline.js"></script>
102 <script src="js/composer.js"></script>
103 <script src="js/app.js"></script>
104</body>
105</html>
hyper-social-web/web/js/api.js +61 -0
……@@ -0,0 +1,61 @@
1/**
2 * API client for hyper-social-web backend
3 */
4const API = {
5 // Base path — empty for direct access, or prefix like '/social' behind reverse proxy
6 // Detected from the path that loaded index.html
7 base: (function () {
8 const p = window.location.pathname.replace(/\/index\.html$/, '').replace(/\/+$/, '')
9 return p || ''
10 })(),
11
12 async post (text, targets) {
13 const res = await fetch(this.base + '/api/post', {
14 method: 'POST',
15 headers: { 'Content-Type': 'application/json' },
16 body: JSON.stringify({ text, targets })
17 })
18 return res.json()
19 },
20
21 async timeline (opts = {}) {
22 const params = new URLSearchParams()
23 if (opts.source) params.set('source', opts.source)
24 if (opts.limit) params.set('limit', opts.limit)
25 if (opts.before) params.set('before', opts.before)
26 const qs = params.toString()
27 const res = await fetch(this.base + '/api/timeline' + (qs ? '?' + qs : ''))
28 return res.json()
29 },
30
31 async refresh () {
32 const res = await fetch(this.base + '/api/feed/refresh')
33 return res.json()
34 },
35
36 async accounts () {
37 const res = await fetch(this.base + '/api/accounts')
38 return res.json()
39 },
40
41 async connect (data) {
42 const res = await fetch(this.base + '/api/accounts/connect', {
43 method: 'POST',
44 headers: { 'Content-Type': 'application/json' },
45 body: JSON.stringify(data)
46 })
47 return res.json()
48 },
49
50 async disconnect (id) {
51 const res = await fetch(this.base + '/api/accounts/' + encodeURIComponent(id), {
52 method: 'DELETE'
53 })
54 return res.json()
55 },
56
57 async notifications () {
58 const res = await fetch(this.base + '/api/notifications')
59 return res.json()
60 }
61}
hyper-social-web/web/js/app.js +199 -0
……@@ -0,0 +1,199 @@
1/**
2 * hyper-social-web — SPA router and initialization
3 */
4
5let currentSource = ''
6let oldestTimestamp = null
7
8// --- Router ---
9function route () {
10 const hash = window.location.hash || '#/'
11 const pages = document.querySelectorAll('.page')
12 const links = document.querySelectorAll('.nav-link')
13
14 pages.forEach(p => { p.style.display = 'none' })
15 links.forEach(l => l.classList.remove('active'))
16
17 if (hash.startsWith('#/compose')) {
18 document.getElementById('page-compose').style.display = 'block'
19 document.querySelector('[data-page="compose"]').classList.add('active')
20 } else if (hash.startsWith('#/notifications')) {
21 document.getElementById('page-notifications').style.display = 'block'
22 document.querySelector('[data-page="notifications"]').classList.add('active')
23 loadNotifications()
24 } else if (hash.startsWith('#/settings')) {
25 document.getElementById('page-settings').style.display = 'block'
26 document.querySelector('[data-page="settings"]').classList.add('active')
27 loadAccounts()
28 } else {
29 document.getElementById('page-timeline').style.display = 'block'
30 document.querySelector('[data-page="timeline"]').classList.add('active')
31 loadTimeline()
32 }
33}
34
35// --- Timeline ---
36async function loadTimeline (append) {
37 const container = document.getElementById('timeline')
38 const loadMoreBtn = document.getElementById('load-more')
39
40 if (!append) {
41 container.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center">Loading...</div>'
42 oldestTimestamp = null
43 }
44
45 const opts = { limit: 30 }
46 if (currentSource) opts.source = currentSource
47 if (append && oldestTimestamp) opts.before = oldestTimestamp
48
49 let data
50 try {
51 console.log('[timeline] fetching', API.base + '/api/timeline', opts)
52 data = await API.timeline(opts)
53 console.log('[timeline] got', data.items ? data.items.length : 0, 'items')
54 } catch (err) {
55 console.error('[timeline] fetch error:', err)
56 container.innerHTML = '<div style="color:var(--red);padding:20px;text-align:center">Error loading timeline: ' + err.message + '</div>'
57 return
58 }
59
60 const items = data.items || []
61
62 if (!append) container.innerHTML = ''
63
64 if (items.length === 0 && !append) {
65 container.innerHTML = '<div style="color:var(--text-muted);padding:40px;text-align:center">No posts yet. Connect an account in Settings, then Refresh.</div>'
66 loadMoreBtn.style.display = 'none'
67 return
68 }
69
70 for (const item of items) {
71 container.appendChild(renderPost(item))
72 oldestTimestamp = item.timestamp
73 }
74
75 loadMoreBtn.style.display = items.length >= 30 ? 'block' : 'none'
76}
77
78// --- Notifications ---
79async function loadNotifications () {
80 const container = document.getElementById('notifications')
81 container.innerHTML = '<div style="color:var(--text-muted);padding:20px;text-align:center">Loading...</div>'
82
83 const data = await API.notifications()
84 const notifs = data.notifications || []
85 container.innerHTML = ''
86
87 if (notifs.length === 0) {
88 container.innerHTML = '<div style="color:var(--text-muted);padding:40px;text-align:center">No notifications</div>'
89 return
90 }
91
92 for (const n of notifs) {
93 container.appendChild(renderNotification(n))
94 }
95}
96
97// --- Accounts ---
98async function loadAccounts () {
99 const container = document.getElementById('accounts-list')
100 container.innerHTML = ''
101
102 const data = await API.accounts()
103 const accounts = data.accounts || []
104
105 if (accounts.length === 0) {
106 container.innerHTML = '<div style="color:var(--text-muted);padding:12px">No accounts connected</div>'
107 return
108 }
109
110 for (const account of accounts) {
111 container.appendChild(renderAccountCard(account))
112 }
113}
114
115async function disconnectAccount (id) {
116 if (!confirm('Disconnect this account?')) return
117 await API.disconnect(id)
118 loadAccounts()
119}
120
121// --- Init ---
122document.addEventListener('DOMContentLoaded', () => {
123 initComposer()
124
125 // Filter buttons
126 document.querySelectorAll('.filter-btn').forEach(btn => {
127 btn.addEventListener('click', () => {
128 document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'))
129 btn.classList.add('active')
130 currentSource = btn.dataset.source
131 loadTimeline()
132 })
133 })
134
135 // Refresh button
136 document.getElementById('refresh-btn').addEventListener('click', async () => {
137 const btn = document.getElementById('refresh-btn')
138 btn.textContent = 'Refreshing...'
139 btn.disabled = true
140 try {
141 const data = await API.refresh()
142 btn.textContent = `Refreshed (${data.refreshed} new)`
143 loadTimeline()
144 } catch (err) {
145 btn.textContent = 'Error'
146 }
147 setTimeout(() => { btn.textContent = 'Refresh'; btn.disabled = false }, 2000)
148 })
149
150 // Load more
151 document.getElementById('load-more').addEventListener('click', () => loadTimeline(true))
152
153 // Platform fields toggle
154 document.getElementById('connect-platform').addEventListener('change', (e) => {
155 document.getElementById('mastodon-fields').style.display = e.target.value === 'mastodon' ? 'block' : 'none'
156 document.getElementById('atproto-fields').style.display = e.target.value === 'atproto' ? 'block' : 'none'
157 })
158
159 // Connect account
160 document.getElementById('connect-btn').addEventListener('click', async () => {
161 const platform = document.getElementById('connect-platform').value
162 const resultEl = document.getElementById('connect-result')
163 let data
164
165 if (platform === 'mastodon') {
166 data = {
167 platform: 'mastodon',
168 instance: document.getElementById('masto-instance').value.trim(),
169 token: document.getElementById('masto-token').value.trim()
170 }
171 if (!data.instance || !data.token) { showResult(resultEl, 'Instance and token required', 'error'); return }
172 data.handle = data.instance
173 } else if (platform === 'atproto') {
174 data = {
175 platform: 'atproto',
176 handle: document.getElementById('bsky-handle').value.trim(),
177 password: document.getElementById('bsky-password').value.trim(),
178 instance: document.getElementById('bsky-service').value.trim() || 'https://bsky.social'
179 }
180 if (!data.handle || !data.password) { showResult(resultEl, 'Handle and app password required', 'error'); return }
181 }
182
183 try {
184 const res = await API.connect(data)
185 if (res.error) {
186 showResult(resultEl, res.error, 'error')
187 } else {
188 showResult(resultEl, 'Connected: ' + (res.account?.id || 'success'), 'success')
189 loadAccounts()
190 }
191 } catch (err) {
192 showResult(resultEl, err.message, 'error')
193 }
194 })
195
196 // Route
197 window.addEventListener('hashchange', route)
198 route()
199})
hyper-social-web/web/js/composer.js +133 -0
……@@ -0,0 +1,133 @@
1/**
2 * POSSE Composer — text + media upload
3 */
4
5let selectedFile = null
6
7function initComposer () {
8 const textarea = document.getElementById('compose-text')
9 const charCount = document.getElementById('char-count')
10 const publishBtn = document.getElementById('publish-btn')
11 const resultEl = document.getElementById('publish-result')
12 const mediaInput = document.getElementById('media-input')
13 const attachBtn = document.getElementById('attach-btn')
14 const mediaPreview = document.getElementById('media-preview')
15 const previewImg = document.getElementById('preview-img')
16 const previewName = document.getElementById('preview-name')
17 const removeMedia = document.getElementById('remove-media')
18 const altText = document.getElementById('alt-text')
19
20 textarea.addEventListener('input', () => {
21 charCount.textContent = textarea.value.length
22 })
23
24 // Attach media
25 attachBtn.addEventListener('click', () => mediaInput.click())
26
27 mediaInput.addEventListener('change', () => {
28 const file = mediaInput.files[0]
29 if (!file) return
30
31 selectedFile = file
32 previewName.textContent = file.name + ' (' + formatSize(file.size) + ')'
33 altText.style.display = 'block'
34
35 if (file.type.startsWith('image/')) {
36 const url = URL.createObjectURL(file)
37 previewImg.src = url
38 previewImg.style.display = 'block'
39 } else {
40 previewImg.style.display = 'none'
41 }
42
43 mediaPreview.style.display = 'flex'
44 attachBtn.style.display = 'none'
45 })
46
47 removeMedia.addEventListener('click', () => {
48 selectedFile = null
49 mediaInput.value = ''
50 mediaPreview.style.display = 'none'
51 attachBtn.style.display = 'inline-block'
52 altText.style.display = 'none'
53 altText.value = ''
54 previewImg.src = ''
55 })
56
57 // Publish
58 publishBtn.addEventListener('click', async () => {
59 const text = textarea.value.trim()
60 if (!text) return
61
62 const targets = []
63 document.querySelectorAll('input[name="target"]:checked').forEach(cb => {
64 targets.push(cb.value)
65 })
66
67 if (targets.length === 0) {
68 showResult(resultEl, 'Select at least one platform', 'error')
69 return
70 }
71
72 publishBtn.disabled = true
73 publishBtn.textContent = selectedFile ? 'Processing & publishing...' : 'Publishing...'
74
75 try {
76 let data
77
78 if (selectedFile) {
79 // Multipart upload with media
80 const form = new FormData()
81 form.append('text', text)
82 form.append('targets', JSON.stringify(targets))
83 form.append('altText', altText.value || '')
84 form.append('file', selectedFile)
85
86 const res = await fetch(API.base + '/api/post-media', {
87 method: 'POST',
88 body: form
89 })
90 data = await res.json()
91 } else {
92 // Text-only
93 data = await API.post(text, targets)
94 }
95
96 if (data.error) {
97 showResult(resultEl, data.error, 'error')
98 } else if (data.errors && Object.keys(data.errors).length > 0) {
99 const errMsg = Object.entries(data.errors).map(([k, v]) => `${k}: ${v}`).join(', ')
100 showResult(resultEl, 'Partial: ' + errMsg, 'error')
101 } else {
102 showResult(resultEl, 'Published to ' + targets.join(', '), 'success')
103 textarea.value = ''
104 charCount.textContent = '0'
105 // Clear media
106 selectedFile = null
107 mediaInput.value = ''
108 mediaPreview.style.display = 'none'
109 attachBtn.style.display = 'inline-block'
110 altText.style.display = 'none'
111 altText.value = ''
112 }
113 } catch (err) {
114 showResult(resultEl, err.message, 'error')
115 }
116
117 publishBtn.disabled = false
118 publishBtn.textContent = 'Publish'
119 })
120}
121
122function formatSize (bytes) {
123 if (bytes < 1024) return bytes + ' B'
124 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
125 return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
126}
127
128function showResult (el, msg, type) {
129 el.style.display = 'block'
130 el.className = 'result ' + type
131 el.textContent = msg
132 setTimeout(() => { el.style.display = 'none' }, 5000)
133}
hyper-social-web/web/js/timeline.js +113 -0
……@@ -0,0 +1,113 @@
1/**
2 * Timeline rendering
3 */
4
5function renderPost (item) {
6 const el = document.createElement('div')
7 el.className = 'post' + (item.type === 'boost' ? ' post-type-boost' : '') + (item.type === 'reply' ? ' post-type-reply' : '')
8
9 const platformLabel = {
10 mastodon: 'Mastodon',
11 atproto: 'Bluesky',
12 instagram: 'Instagram',
13 local: 'Local'
14 }
15
16 const time = new Date(item.timestamp)
17 const timeStr = time.toLocaleDateString() + ' ' + time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
18
19 let boostInfo = ''
20 if (item.type === 'boost' && item.metadata?.boostBy) {
21 boostInfo = `<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">${item.metadata.boostBy} reposted</div>`
22 }
23
24 let mediaHtml = ''
25 if (item.mediaUrl) {
26 if (item.mediaType === 'image' || item.mediaType === 'gifv') {
27 mediaHtml = `<div class="post-media"><img src="${escapeHtml(item.mediaUrl)}" loading="lazy" alt=""></div>`
28 } else if (item.mediaType === 'video') {
29 mediaHtml = `<div class="post-media"><a href="${escapeHtml(item.mediaUrl)}" target="_blank">[video]</a></div>`
30 }
31 }
32
33 let metaHtml = ''
34 if (item.metadata) {
35 const parts = []
36 if (item.metadata.likeCount) parts.push(item.metadata.likeCount + ' likes')
37 if (item.metadata.repostCount) parts.push(item.metadata.repostCount + ' reposts')
38 if (item.metadata.replyCount) parts.push(item.metadata.replyCount + ' replies')
39 if (parts.length > 0) metaHtml = `<div class="post-meta">${parts.join(' · ')}</div>`
40 }
41
42 let syndicatedHtml = ''
43 if (item.syndicated) {
44 const platforms = Object.values(item.syndicated).map(s => s.platform).join(', ')
45 syndicatedHtml = `<div class="post-syndicated">Published to: ${platforms}</div>`
46 }
47
48 let urlHtml = ''
49 if (item.url) {
50 urlHtml = `<div style="margin-top:4px"><a href="${escapeHtml(item.url)}" target="_blank" style="color:var(--accent);font-size:12px">View on ${platformLabel[item.source] || item.source}</a></div>`
51 }
52
53 const avatar = item.metadata?.avatar || ''
54 const displayName = item.metadata?.displayName || ''
55 const avatarHtml = avatar ? `<img class="post-avatar" src="${escapeHtml(avatar)}" alt="">` : `<div class="post-avatar post-avatar-placeholder"></div>`
56
57 el.innerHTML = `
58 ${boostInfo}
59 <div class="post-header">
60 ${avatarHtml}
61 <div class="post-author-info">
62 ${displayName ? `<span class="post-displayname">${escapeHtml(displayName)}</span>` : ''}
63 <span class="post-author">${escapeHtml(item.author)}</span>
64 </div>
65 <span class="post-platform ${item.source}">${platformLabel[item.source] || item.source}</span>
66 <span class="post-time">${timeStr}</span>
67 </div>
68 <div class="post-text">${escapeHtml(item.text)}</div>
69 ${mediaHtml}
70 ${metaHtml}
71 ${syndicatedHtml}
72 ${urlHtml}
73 `
74 return el
75}
76
77function renderNotification (notif) {
78 const el = document.createElement('div')
79 el.className = 'notification'
80
81 const platformLabel = { mastodon: 'Mastodon', atproto: 'Bluesky' }
82 const time = new Date(notif.timestamp)
83 const timeStr = time.toLocaleDateString() + ' ' + time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
84
85 el.innerHTML = `
86 <div class="post-header">
87 <span class="notif-type">${escapeHtml(notif.type)}</span>
88 <span class="post-platform ${notif.platform}">${platformLabel[notif.platform] || notif.platform}</span>
89 <span class="post-time">${timeStr}</span>
90 </div>
91 <div><strong>${escapeHtml(notif.author)}</strong></div>
92 ${notif.text ? `<div class="post-text">${escapeHtml(notif.text)}</div>` : ''}
93 `
94 return el
95}
96
97function renderAccountCard (account) {
98 const el = document.createElement('div')
99 el.className = 'account-card'
100 el.innerHTML = `
101 <div class="account-info">
102 <span class="post-platform ${account.platform}">${account.platform}</span>
103 <span>${escapeHtml(account.handle || account.instance || account.id)}</span>
104 </div>
105 <button class="btn btn-small btn-danger" onclick="disconnectAccount('${escapeHtml(account.id)}')">Disconnect</button>
106 `
107 return el
108}
109
110function escapeHtml (str) {
111 if (!str) return ''
112 return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
113}