+45
-24
packages/client/app/components/PostFeed.tsx
+45
-24
packages/client/app/components/PostFeed.tsx
···
1
+
import {
2
+
parseCanonicalResourceUri,
3
+
type Did,
4
+
type ResourceUri
5
+
} from '@atcute/lexicons'
1
6
import type { FeedItem } from '@www/lib/post.server'
7
+
import { useRootData } from '@www/lib/useRootData'
2
8
import { Form } from 'react-router'
3
9
4
10
const postVisibilityLabels: Record<string, string> = {
···
7
13
mentioned: 'Mentioned only'
8
14
}
9
15
16
+
function atUriToDid(uri: ResourceUri) {
17
+
const parsedUri = parseCanonicalResourceUri(uri)
18
+
if (!parsedUri.ok) {
19
+
throw new Error(`invalid AT uri: ${parsedUri.error}`)
20
+
}
21
+
const did = parsedUri.value.repo
22
+
return did as Did
23
+
}
24
+
10
25
export default function PostFeed({ feed }: { feed: FeedItem[] }) {
26
+
const { user } = useRootData()
27
+
const me = user?.identity.did
28
+
const isMe = (uri: ResourceUri) => atUriToDid(uri) === me
29
+
11
30
return (
12
31
<ul className="divide-accent-content">
13
32
{feed.length === 0 ? (
···
20
39
key={post.uri}
21
40
className="relative px-4 py-3 mb-6 border border-neutral rounded-box"
22
41
>
23
-
<div className="top-1 right-1 absolute">
24
-
<Form method="POST" className="tooltip" data-tip="Delete">
25
-
<input type="hidden" name="uri" value={post.uri} />
26
-
<button
27
-
name="action"
28
-
value="delete"
29
-
className="btn btn-sm btn-square btn-ghost"
30
-
>
31
-
<svg
32
-
xmlns="http://www.w3.org/2000/svg"
33
-
className="h-4 w-4"
34
-
fill="none"
35
-
viewBox="0 0 24 24"
36
-
stroke="currentColor"
42
+
{isMe(post.uri) ? (
43
+
<div className="top-1 right-1 absolute">
44
+
<Form method="POST" className="tooltip" data-tip="Delete">
45
+
<input type="hidden" name="uri" value={post.uri} />
46
+
<button
47
+
name="action"
48
+
value="delete"
49
+
className="btn btn-sm btn-square btn-ghost"
37
50
>
38
-
<path
39
-
strokeLinecap="round"
40
-
strokeLinejoin="round"
41
-
strokeWidth={2}
42
-
d="M6 18L18 6M6 6l12 12"
43
-
/>
44
-
</svg>
45
-
</button>
46
-
</Form>
47
-
</div>
51
+
<svg
52
+
xmlns="http://www.w3.org/2000/svg"
53
+
className="h-4 w-4"
54
+
fill="none"
55
+
viewBox="0 0 24 24"
56
+
stroke="currentColor"
57
+
>
58
+
<path
59
+
strokeLinecap="round"
60
+
strokeLinejoin="round"
61
+
strokeWidth={2}
62
+
d="M6 18L18 6M6 6l12 12"
63
+
/>
64
+
</svg>
65
+
</button>
66
+
</Form>
67
+
</div>
68
+
) : null}
48
69
<p className="text-sm mb-2 text-base-content/90">@{post.handle}</p>
49
70
<p className="mb-3 text-base">
50
71
{post.content.contentWarning
+3
-3
packages/client/app/components/UserMenu.tsx
+3
-3
packages/client/app/components/UserMenu.tsx
···
33
33
>
34
34
{avatar ? (
35
35
<div className="w-10 rounded-full">
36
-
<img src={avatar} alt={handle} className="w-10" />
36
+
<img src={avatar} alt={handle ?? ''} className="w-10" />
37
37
</div>
38
38
) : (
39
39
<div className="bg-accent text-base-content w-10 rounded-full">
···
46
46
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-10 mt-1 w-52 p-2 shadow"
47
47
>
48
48
<li>
49
-
<a>Profile</a>
49
+
<Link to={`/profile/${handle}`}>Your profile</Link>
50
50
</li>
51
51
<li>
52
-
<Link to="/settings/delegation">Delegation Settings</Link>
52
+
<Link to="/settings/delegation">Private posts setup</Link>
53
53
</li>
54
54
<li>
55
55
<button onClick={logout}>Logout</button>
+20
-20
packages/client/app/lib/post.server.ts
+20
-20
packages/client/app/lib/post.server.ts
···
25
25
import type { OAuthSession } from '@atproto/oauth-client-node'
26
26
import type { AtprotoDid as Did, ResourceUri } from '@atcute/lexicons/syntax'
27
27
import { atUriToDid, didToHandle, handleToDid } from './idResolver.server'
28
+
import asyncWrap from './asyncWrap'
28
29
29
30
type PostPayload = {
30
31
visibility: AppWafrnContentDefs.PrivatePostView['visibility']
···
158
159
visibility: 'public'
159
160
})
160
161
} else if (is(AppWafrnContentDefs.privatePostViewSchema, record)) {
161
-
try {
162
-
const plainText = await keyClient.decrypt(
162
+
const [plainText, error] = await asyncWrap(() =>
163
+
keyClient.decrypt(
163
164
record.uri,
164
165
`${did}#${record.visibility}`,
165
166
record.encryptedContent,
166
167
record.keyVersion
167
168
)
168
-
const postContent = JSON.parse(
169
-
plainText
170
-
) as AppWafrnContentDefs.PostContent
169
+
)
170
+
let content = {} as AppWafrnContentDefs.PostContent
171
+
if (error) {
172
+
console.error(error)
173
+
const isMe = session.did === did
174
+
// only show "could not decrypt this post" if it's the user's post
175
+
if (isMe) {
176
+
content = {
177
+
contentMarkdown: 'Could not decrypt this post',
178
+
contentHTML: 'Could not decrypt this post'
179
+
}
180
+
}
181
+
} else {
182
+
content = JSON.parse(plainText) as AppWafrnContentDefs.PostContent
183
+
}
184
+
if (content.contentMarkdown || content.contentHTML) {
171
185
posts.push({
172
186
$type: 'app.wafrn.content.defs#publicPostView',
173
187
handle,
174
188
uri: record.uri,
175
-
content: postContent,
176
-
createdAt: record.createdAt,
177
-
updatedAt: record.updatedAt,
178
-
visibility: record.visibility
179
-
})
180
-
} catch (error) {
181
-
console.error('Error decrypting private post:', error)
182
-
posts.push({
183
-
$type: 'app.wafrn.content.defs#publicPostView',
184
-
handle,
185
-
uri: record.uri,
186
-
content: {
187
-
contentMarkdown: 'Could not decrypt this post',
188
-
contentHTML: 'Could not decrypt this post'
189
-
},
189
+
content,
190
190
createdAt: record.createdAt,
191
191
updatedAt: record.updatedAt,
192
192
visibility: record.visibility
+29
-14
packages/client/app/routes/settings.delegation.tsx
+29
-14
packages/client/app/routes/settings.delegation.tsx
···
43
43
delegate_did: env.API_SERVICE_DID,
44
44
permissions: ['add_member', 'remove_member']
45
45
})
46
-
return data({ success: true, message: 'Server authorized successfully!' })
46
+
return data({
47
+
success: true,
48
+
message:
49
+
'Private posts enabled! You can now create posts that only your followers can read.'
50
+
})
47
51
} else if (action === 'revoke') {
48
52
await keyClient.revokeDelegate({
49
53
group_id: groupId,
50
54
delegate_did: env.API_SERVICE_DID
51
55
})
52
-
return data({ success: true, message: 'Server authorization revoked!' })
56
+
return data({
57
+
success: true,
58
+
message:
59
+
'Private posts disabled. The server can no longer manage your follower encryption group.'
60
+
})
53
61
}
54
62
} catch (error) {
55
63
return data(
···
73
81
74
82
return (
75
83
<div className="px-4 py-6 max-w-3xl mx-auto">
76
-
<h1 className="text-2xl font-bold mb-3">Delegation Settings</h1>
84
+
<h1 className="text-2xl font-bold mb-3">Private Post Setup</h1>
77
85
78
86
<div className="card bg-base-200 shadow-xl mb-4">
79
87
<div className="card-body">
80
-
<h2 className="card-title">Server Authorization</h2>
88
+
<h2 className="card-title">
89
+
Let the server manage your follower encryption
90
+
</h2>
81
91
<p className="text-sm opacity-70 mb-4">
82
-
Authorize the application server to manage your follower group on
83
-
the keyserver. This allows the server to automatically add followers
84
-
to your encryption group when they follow you.
92
+
Want to create private posts that only your followers can read?
93
+
You'll need to authorize the server to manage your follower
94
+
encryption group. When someone follows you, the server will
95
+
automatically add them to the group so they can decrypt your private
96
+
posts. Without this, private posts won't work unless you enable
97
+
manual follow approval (not implemented yet).
85
98
</p>
86
99
87
100
<div className="grid grid-cols-2 gap-2 text-sm mb-4">
···
94
107
<div className="font-semibold">Server DID:</div>
95
108
<div className="font-mono text-xs break-all">{apiServiceDid}</div>
96
109
97
-
<div className="font-semibold">Status:</div>
110
+
<div className="font-semibold">Private Posts:</div>
98
111
<div>
99
112
{isAuthorized ? (
100
-
<span className="badge badge-success">Authorized</span>
113
+
<span className="badge badge-success">Enabled</span>
101
114
) : (
102
-
<span className="badge badge-warning">Not Authorized</span>
115
+
<span className="badge badge-warning">Disabled</span>
103
116
)}
104
117
</div>
105
118
</div>
106
119
107
120
{actionData?.message ? (
108
121
<div
109
-
className={`alert ${actionData.success ? 'alert-success' : 'alert-error'} mb-4`}
122
+
className={`alert alert-soft ${actionData.success ? 'alert-success' : 'alert-error'} mb-4`}
110
123
>
111
124
{actionData.message}
112
125
</div>
113
126
) : null}
114
127
115
128
{error ? (
116
-
<div className="alert alert-error mb-4">{error.message}</div>
129
+
<div className="alert alert-soft alert-error mb-4">
130
+
{error.message}
131
+
</div>
117
132
) : null}
118
133
119
134
<Form method="POST" className="card-actions">
···
124
139
className="btn btn-warning"
125
140
type="submit"
126
141
>
127
-
Revoke Authorization
142
+
Turn Off (Disable Private Posts)
128
143
</button>
129
144
) : (
130
145
<button
···
133
148
className="btn btn-primary"
134
149
type="submit"
135
150
>
136
-
Authorize Server
151
+
Enable Private Posts
137
152
</button>
138
153
)}
139
154
</Form>