Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/www2/public/viche-en.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/www2/public/viche-uk.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions apps/www2/src/components/ProjectCard.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import { Image } from 'astro:assets'
import { Camera, Globe, Music, Package, Terminal } from '@lucide/astro'
import IntersectThree from '@/components/icons/IntersectThree.astro'
import type { Project } from '@/data/projects'
import type { GithubStats } from '@/utils/fetchGithubStats'
import type { NpmStats } from '@/utils/fetchNpmStats'
Expand All @@ -21,6 +22,7 @@ const iconMap = {
terminal: Terminal,
music: Music,
camera: Camera,
'intersect-three': IntersectThree,
} as const
const { project, locale, npm, github } = Astro.props
const title = project.title[locale]
Expand Down
25 changes: 25 additions & 0 deletions apps/www2/src/components/icons/IntersectThree.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
import { brandIcons } from '@/utils/brandIcons'

/**
* Viche's mark. Path data comes from `brandIcons` so this and the generated OG
* card cannot drift apart; see that module for why it is not Lucide.
*/
interface Props {
class?: string
}

const { class: className } = Astro.props
const { viewBox, fill, outline } = brandIcons['intersect-three']
---

<svg
viewBox={viewBox}
fill="currentColor"
class={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<path d={fill} opacity="0.2"></path>
<path d={outline}></path>
</svg>
2 changes: 1 addition & 1 deletion apps/www2/src/data/feedPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface FeedPage {
}

export const feedPages: FeedPage[] = [
{ path: '/cv', key: 'cv', publishedAt: '2026-03-09', updatedAt: '2026-07-26' },
{ path: '/cv', key: 'cv', publishedAt: '2026-03-09', updatedAt: '2026-07-29' },
{ path: '/contact', key: 'contact', publishedAt: '2026-03-10' },
{ path: '/open-source', key: 'openSource', publishedAt: '2026-03-10' },
]
76 changes: 68 additions & 8 deletions apps/www2/src/data/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,66 @@ export const defaultAuthor: ProjectCredit = {
}

export const projects: Project[] = [
{
slug: 'viche',
title: { en: 'viche.social', uk: 'viche.social' },
description: {
en: 'Bluesky, Mastodon and Nostr in one timeline — a client-first web app where your keys and accounts stay on your device.',
uk: 'Bluesky, Mastodon і Nostr в одній стрічці — клієнтський застосунок, де ваші ключі та акаунти лишаються на вашому пристрої.',
},
category: 'dev',
status: 'live',
startedAt: '2026-04-21',
publishedAt: '2026-07-29',
programmingLanguages: ['TypeScript'],
tags: ['bluesky', 'mastodon', 'nostr', 'pwa', 'atproto', 'activitypub', 'astro', 'react'],
icon: 'intersect-three',
url: 'https://viche.social',
related: ['countries-list', 'dmythro-com'],
organization: { name: 'Annexare', url: 'https://annexare.com/' },
uses: [
{
name: 'Astro',
url: 'https://astro.build/',
role: {
en: 'static shell, one React island',
uk: 'статична оболонка, один React-острівець',
},
},
{
name: 'HeroUI',
url: 'https://www.heroui.com/',
role: { en: 'component library', uk: 'бібліотека компонентів' },
},
{
name: 'nostr-tools',
url: 'https://github.com/nbd-wtf/nostr-tools',
role: { en: 'Nostr relays and signing', uk: 'релеї та підписи Nostr' },
},
{
name: 'AT Protocol',
url: 'https://atproto.com/',
role: { en: 'Bluesky OAuth and lexicons', uk: 'OAuth та лексикони Bluesky' },
},
{
name: 'TanStack Query',
url: 'https://tanstack.com/query',
role: { en: 'remote cache', uk: 'кеш віддалених даних' },
},
{
name: 'Lexical',
url: 'https://lexical.dev/',
role: { en: 'the composer', uk: 'редактор допису' },
},
{
name: 'countries-list',
url: 'https://github.com/annexare/Countries',
role: { en: 'locale data', uk: 'дані локалей' },
},
],
isHighlighted: true,
sortOrder: 1,
},
// annexare pinned repos (bigger, more important)
{
slug: 'countries-list',
Expand Down Expand Up @@ -134,7 +194,7 @@ export const projects: Project[] = [
],
fallbackStars: 1306,
isHighlighted: true,
sortOrder: 1,
sortOrder: 2,
},
{
slug: 'jsonl-logger',
Expand Down Expand Up @@ -169,7 +229,7 @@ export const projects: Project[] = [
organization: { name: 'Annexare', url: 'https://annexare.com/' },
fallbackStars: 2,
isHighlighted: true,
sortOrder: 2,
sortOrder: 3,
},
{
slug: 'graphql-suite',
Expand Down Expand Up @@ -221,7 +281,7 @@ export const projects: Project[] = [
],
fallbackStars: 3,
isHighlighted: true,
sortOrder: 3,
sortOrder: 4,
},
// dmythro pinned repos
{
Expand Down Expand Up @@ -275,7 +335,7 @@ export const projects: Project[] = [
fallbackStars: 6,
url: 'https://dmythro.com',
isHighlighted: true,
sortOrder: 4,
sortOrder: 5,
},
{
slug: 'terminal-setup',
Expand Down Expand Up @@ -307,7 +367,7 @@ export const projects: Project[] = [
],
fallbackStars: 4,
isHighlighted: true,
sortOrder: 5,
sortOrder: 6,
},
{
slug: 'agent-skills',
Expand Down Expand Up @@ -335,7 +395,7 @@ export const projects: Project[] = [
],
fallbackStars: 3,
isHighlighted: true,
sortOrder: 6,
sortOrder: 7,
},
{
slug: 'envs',
Expand Down Expand Up @@ -366,7 +426,7 @@ envs setup`,
],
fallbackStars: 0,
isHighlighted: true,
sortOrder: 7,
sortOrder: 8,
},
// Tools that live on this site rather than in a repo of their own
{
Expand Down Expand Up @@ -411,7 +471,7 @@ envs setup`,
],
fallbackStars: 6,
isHighlighted: true,
sortOrder: 8,
sortOrder: 9,
},
]

Expand Down
2 changes: 2 additions & 0 deletions apps/www2/src/pages/[locale]/projects/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Github } from 'simple-icons-astro'
import ArticleToc from '@/components/ArticleToc.astro'
import BackLink from '@/components/BackLink.astro'
import InstallCommand from '@/components/InstallCommand.astro'
import IntersectThree from '@/components/icons/IntersectThree.astro'
import * as mdxComponents from '@/components/mdx'
import ProjectCard from '@/components/ProjectCard.astro'
import ProjectMeta from '@/components/ProjectMeta.astro'
Expand Down Expand Up @@ -60,6 +61,7 @@ const iconMap = {
terminal: Terminal,
music: Music,
camera: Camera,
'intersect-three': IntersectThree,
} as const
const IconComponent = iconMap[project.icon as keyof typeof iconMap]

Expand Down
24 changes: 24 additions & 0 deletions apps/www2/src/utils/brandIcons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Marks that are not Lucide, and so do not share its 24px stroke grid: they sit
* on their own viewBox and are drawn with fills rather than strokes.
*
* The path data lives here because two places draw it — the Astro component for
* the page and the card, and `ogImage` for the generated card — and a copy in
* each would drift the moment one was touched.
*/
export interface BrandIcon {
viewBox: string
/** Drawn first, under the outline, at reduced opacity. */
fill: string
outline: string
}

export const brandIcons: Record<string, BrandIcon> = {
/** Viche's mark — Phosphor's `IntersectThree`, duotone, as its app header draws it. */
'intersect-three': {
viewBox: '0 0 256 256',
fill: 'M104.64,147.28a60,60,0,0,1-36-46.56A60.06,60.06,0,0,1,128,108,59.94,59.94,0,0,0,104.64,147.28Zm0,0A60,60,0,0,0,128,204a60,60,0,0,0,23.36-56.72,60.17,60.17,0,0,1-46.72,0Zm82.72-46.56A60.06,60.06,0,0,0,128,108a59.94,59.94,0,0,1,23.36,39.28A60,60,0,0,0,187.36,100.72Z',
outline:
'M195.88,96c.07-1.31.12-2.63.12-4A68,68,0,0,0,60,92c0,1.33,0,2.65.12,4A68,68,0,1,0,128,213.65,68,68,0,1,0,195.88,96ZM128,193.47a51.89,51.89,0,0,1-16-35.38,67.55,67.55,0,0,0,31.9,0A51.89,51.89,0,0,1,128,193.47ZM128,144a51.93,51.93,0,0,1-14.08-1.95A52.06,52.06,0,0,1,128,118.53a52.06,52.06,0,0,1,14.08,23.52A51.93,51.93,0,0,1,128,144Zm-28.77-8.71A52.19,52.19,0,0,1,77.92,106a51.88,51.88,0,0,1,36.79,3.28A68.17,68.17,0,0,0,99.23,135.29Zm42.06-26.06A51.88,51.88,0,0,1,178.08,106a52.19,52.19,0,0,1-21.31,29.34A68.17,68.17,0,0,0,141.29,109.23ZM128,40A52.06,52.06,0,0,1,180,89.91,67.72,67.72,0,0,0,128,98.35a67.72,67.72,0,0,0-51.95-8.44A52.06,52.06,0,0,1,128,40ZM40,156a52,52,0,0,1,23.23-43.29A68.36,68.36,0,0,0,96.12,152c-.07,1.31-.12,2.63-.12,4a67.74,67.74,0,0,0,18.71,46.77A52,52,0,0,1,40,156Zm124,52a51.65,51.65,0,0,1-22.71-5.23A67.74,67.74,0,0,0,160,156c0-1.33-.05-2.65-.12-4a68.36,68.36,0,0,0,32.89-39.33A52,52,0,0,1,164,208Z',
},
}
9 changes: 9 additions & 0 deletions apps/www2/src/utils/ogImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { googleFonts } from 'takumi-js/helpers'

import type { Project, ProjectStatus } from '@/data/projects'

import { brandIcons } from './brandIcons'
import type { LocaleCode } from './i18n'

export const OG_WIDTH = 1200
Expand Down Expand Up @@ -56,6 +57,14 @@ const iconShapes: Record<string, string> = {

/** Inline SVG as a data URI — Takumi renders it as an image node. */
function iconDataUri(icon: string, color: string): string {
// Not Lucide, so it brings its own viewBox and fills instead of strokes.
const brand = brandIcons[icon]
if (brand) {
const shapes = `<path opacity="0.2" d="${brand.fill}"/><path d="${brand.outline}"/>`
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${brand.viewBox}" fill="${color}">${shapes}</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}

const shapes = iconShapes[icon] ?? iconShapes.package
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${shapes}</svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
Expand Down
64 changes: 64 additions & 0 deletions packages/locales/mdx/projects/viche.en.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
![Viche — Bluesky, Mastodon and Nostr in one timeline](/viche-en.webp)

I have accounts on Bluesky, Mastodon and Nostr, and the people I actually want to read are split across all three. So I keep three apps open, check them in rotation, and still miss things — because the timeline I want doesn't exist in any of them.

Viche is that timeline. It runs in the browser, holds accounts on all three networks at once, and lets you read them together or apart. The name is the Ukrainian _Віче_ — an ancient popular assembly, where people gathered to decide things together.

Nothing to install: [viche.app](https://viche.app) is the product, [viche.social](https://viche.social) is the marketing and docs.

## Why it exists

The three networks solve overlapping problems in incompatible ways, and each of them wants to be the one you pick. Picking one means losing touch with the people who picked differently, so most of us end up running all three and doing the merging in our heads.

That merge is the product. And the place to do it is the client, not another protocol or another server: a browser already speaks WebSockets to Nostr relays, HTTP to Mastodon instances, and OAuth to a Bluesky PDS. Nothing has to sit in the middle — so nothing does.

## How it's different

| Approach | What it optimises for |
| --- | --- |
| Viche | many accounts in one timeline or read apart, credentials on your device |
| A single-network client | depth on one network — every feature that network has, first |
| Cross-posting schedulers | publishing the same thing everywhere, not reading |
| Running three apps | nothing, but it works today |

**Use a native client for your main network if you mostly live on one.** A dedicated Bluesky or Mastodon app will always be ahead on that network's newest features, because it only has one to track. Viche is for the case where your attention is genuinely split, and the cost of that split is the thing worth fixing.

## What's inside

### One vocabulary, three adapters

`@viche/core` is types and static data — `Network`, `Post`, `Session`, `NetworkAdapter`, and a descriptor per network. Each adapter implements that interface for its protocol, and nothing else in the app imports a protocol library directly. Adapters load through `import()` when an account needs one, so the relay pool and the AT Protocol client stay out of the bundle until you sign in.

### Capabilities, not conditionals

A descriptor carries a set of capability slugs — `read-timeline`, `write-post`, `read-poll`. The UI gates on set membership, so adding a network means adding a descriptor and an adapter rather than auditing every component for a new branch.

Those same sets generate the [public feature matrix](https://viche.app/about), which is why it can't drift: the page asks the descriptor rather than reading a list someone maintains by hand. Each cell has three states — shipped, coming, or not possible on that protocol — and the third is the one worth having. "We haven't built this" and "this network cannot do this" are different things, and collapsing them into one blank cell is how a comparison table drifts from reality.

### One feed, or several

Merging is a setting, not the shape of the app. **Merge feeds**, in Settings, has three modes: _All_ pulls every account you have added into one timeline, _Selected_ pulls only the ones you tick, and _None_ turns merging off — you read one account at a time and switch between them.

Sources are accounts, not networks, and nothing caps how many you add. Two Mastodon logins and three Nostr ones are five sources, and nothing special-cases which protocol any of them came from: the same protocol twice is no different from two protocols once. So the choice is not really "merge my networks" but "which of my accounts am I reading right now", which is the question I actually have.

When they are merged, each source produces its own chronological pages and the timeline interleaves them by timestamp, holding a watermark per source so paging stays consistent as you scroll. Engagement uses whichever account surfaced the post, so liking something never asks which identity you meant by it.

### Credentials that stay put

Nostr signs through a NIP-07 extension or a remote signer; Bluesky uses OAuth with DPoP; Mastodon uses per-instance OAuth. None of it reaches a server of mine, because there isn't one — the app is static files on a CDN.

## Design notes

**The integration seam belongs in the client.** Every server-side attempt at this becomes a new account you have to trust with the keys to the other three. Doing it in the browser means the credentials never leave the device, and the honest privacy claim — that I cannot read your timeline — is architectural rather than a policy I'm asking you to believe.

**Static-only constrains me, not the app.** No backend of mine is in the request path, but the browser still calls relays, AppViews and instance APIs directly. That distinction is easy to lose: it rules out running an indexer, not talking to third parties. Trending hashtags, for instance, are counted from the feed you already loaded rather than from an index I'd have to host.

**`wss://` only, and your identity key never stored.** Plaintext relay URLs are rejected at the normalisation boundary rather than warned about, with a migration that prunes any that were saved earlier. Remote signing persists a throwaway keypair for the session instead of your identity key — the thing you'd most regret leaking is the one thing the app never holds.

**Storage is handed in, not hardcoded.** Nothing in the state layer reaches for the browser's storage on its own — it is told where to write, and whoever starts the app decides what that is. On the web that is `localStorage`, in tests it is a map in memory, and a native build would pass its own. localStorage runs today because it is the simplest thing already there, not because anything is built around it; replacing it means passing something else rather than rewriting anything.

## Status

Live at [viche.app](https://viche.app). Three networks, read merged or one account at a time; sign-in including a remote signer for Nostr; posting with replies, quotes, mentions, hashtags and image upload; direct messages; notifications; follows, mutes and blocks; profile editing across all three; and an Explore surface for people, tags and popular posts.

Open source is the plan, not the current state. There are questions I want to answer properly rather than quickly.
Loading