Skip to content

Commit f624d0b

Browse files
authored
Merge pull request #19 from eurosky-social/eurosky/fork
edit posts
2 parents 00409eb + bb3471d commit f624d0b

18 files changed

Lines changed: 615 additions & 61 deletions

File tree

src/components/PostControls/PostMenu/PostMenuItems.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {useLingui} from '@lingui/react/macro'
1818
import {useNavigation} from '@react-navigation/native'
1919

2020
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
21+
import {canEditPost} from '#/lib/edit-post'
22+
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
2123
import {useOpenLink} from '#/lib/hooks/useOpenLink'
2224
import {getCurrentRoute} from '#/lib/routes/helpers'
2325
import {makeProfileLink} from '#/lib/routes/links'
@@ -55,6 +57,7 @@ import {
5557
MaxHiddenRepliesError,
5658
useToggleReplyVisibilityMutation,
5759
} from '#/state/queries/threadgate'
60+
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
5861
import {useRequireAuth, useSession} from '#/state/session'
5962
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
6063
import {useDialogControl} from '#/components/Dialog'
@@ -77,6 +80,7 @@ import {
7780
Mute_Stroke2_Corner0_Rounded as Mute,
7881
Mute_Stroke2_Corner0_Rounded as MuteIcon,
7982
} from '#/components/icons/Mute'
83+
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from '#/components/icons/Pencil'
8084
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
8185
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
8286
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
@@ -170,6 +174,14 @@ let PostMenuItems = ({
170174
)
171175
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
172176
const isAuthor = postAuthor.did === currentAccount?.did
177+
const {openComposer} = useOpenComposer()
178+
const currentProfile = useCurrentAccountProfile()
179+
const canEdit = canEditPost({
180+
isAuthor,
181+
createdAt: record.createdAt,
182+
updatedAt: record.updatedAt,
183+
accountCreatedAt: currentProfile?.createdAt,
184+
})
173185
const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
174186
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
175187
threadgateRecord,
@@ -763,6 +775,22 @@ let PostMenuItems = ({
763775

764776
{isAuthor && (
765777
<>
778+
{canEdit && (
779+
<Menu.Item
780+
testID="postDropdownEditBtn"
781+
label={l`Edit post`}
782+
onPress={() =>
783+
openComposer({
784+
editPost: {
785+
uri: postUri,
786+
text: record.text,
787+
},
788+
})
789+
}>
790+
<Menu.ItemText>{l`Edit post`}</Menu.ItemText>
791+
<Menu.ItemIcon icon={PencilLine} position="right" />
792+
</Menu.Item>
793+
)}
766794
<Menu.Item
767795
testID="postDropdownEditPostInteractions"
768796
label={l`Edit interaction settings`}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import {View} from 'react-native'
2+
import {type AppBskyFeedPost} from '@atproto/api'
3+
import {Trans, useLingui} from '@lingui/react/macro'
4+
5+
import {getPostEditInfo} from '#/lib/edit-post'
6+
import {atoms as a, useTheme, web} from '#/alf'
7+
import * as Dialog from '#/components/Dialog'
8+
import {Text} from '#/components/Typography'
9+
10+
/**
11+
* The "· Edited" badge next to a post's timestamp. Renders nothing unless the
12+
* post was edited; tapping it opens the original-vs-current history.
13+
*/
14+
export function PostEditedIndicator({
15+
record,
16+
size = 'md',
17+
}: {
18+
record: AppBskyFeedPost.Record
19+
size?: 'sm' | 'md'
20+
}) {
21+
const t = useTheme()
22+
const {t: l} = useLingui()
23+
const control = Dialog.useDialogControl()
24+
const {isEdited, originalText, updatedAt} = getPostEditInfo(record)
25+
26+
if (!isEdited) {
27+
return null
28+
}
29+
30+
return (
31+
<>
32+
<Text
33+
accessibilityRole="button"
34+
accessibilityLabel={l`View edit history`}
35+
accessibilityHint={l`Opens the original and edited versions of this post`}
36+
onPress={() => control.open()}
37+
style={[
38+
a.pl_xs,
39+
size === 'sm' ? a.text_sm : a.text_md,
40+
a.leading_tight,
41+
t.atoms.text_contrast_medium,
42+
web({whiteSpace: 'nowrap', cursor: 'pointer'}),
43+
]}>
44+
<Trans context="Indicates a post has been edited">· Edited</Trans>
45+
</Text>
46+
<PostEditHistoryDialog
47+
control={control}
48+
originalText={originalText}
49+
currentText={record.text}
50+
createdAt={record.createdAt}
51+
updatedAt={updatedAt}
52+
/>
53+
</>
54+
)
55+
}
56+
57+
function PostEditHistoryDialog({
58+
control,
59+
originalText,
60+
currentText,
61+
createdAt,
62+
updatedAt,
63+
}: {
64+
control: Dialog.DialogControlProps
65+
originalText: string | undefined
66+
currentText: string
67+
createdAt: string
68+
updatedAt: string | undefined
69+
}) {
70+
const {t: l, i18n} = useLingui()
71+
72+
return (
73+
<Dialog.Outer control={control}>
74+
<Dialog.Handle />
75+
<Dialog.ScrollableInner label={l`Edit history`}>
76+
<Dialog.Header>
77+
<Dialog.HeaderText>
78+
<Trans>Edit history</Trans>
79+
</Dialog.HeaderText>
80+
</Dialog.Header>
81+
<View style={[a.pt_lg]}>
82+
<TimelineEntry
83+
isCurrent
84+
label={
85+
updatedAt
86+
? l`Current · ${i18n.date(new Date(updatedAt), {
87+
dateStyle: 'medium',
88+
timeStyle: 'short',
89+
})}`
90+
: l`Current`
91+
}
92+
text={currentText}
93+
/>
94+
<TimelineEntry
95+
isLast
96+
label={l`Original · ${i18n.date(new Date(createdAt), {
97+
dateStyle: 'medium',
98+
timeStyle: 'short',
99+
})}`}
100+
text={originalText ?? ''}
101+
/>
102+
</View>
103+
<Dialog.Close />
104+
</Dialog.ScrollableInner>
105+
</Dialog.Outer>
106+
)
107+
}
108+
109+
function TimelineEntry({
110+
label,
111+
text,
112+
isCurrent = false,
113+
isLast = false,
114+
}: {
115+
label: string
116+
text: string
117+
isCurrent?: boolean
118+
isLast?: boolean
119+
}) {
120+
const t = useTheme()
121+
return (
122+
<View style={[a.flex_row, a.gap_md]}>
123+
<View style={[a.align_center, {width: 12}]}>
124+
<View
125+
style={[
126+
{width: 12, height: 12, borderRadius: 999, marginTop: 3},
127+
isCurrent
128+
? {backgroundColor: t.palette.primary_500}
129+
: [{borderWidth: 2}, t.atoms.border_contrast_high, t.atoms.bg],
130+
]}
131+
/>
132+
{!isLast && (
133+
<View
134+
style={[
135+
a.flex_1,
136+
{width: 2, marginTop: 3, backgroundColor: t.palette.contrast_200},
137+
]}
138+
/>
139+
)}
140+
</View>
141+
<View style={[a.flex_1, a.gap_xs, !isLast && a.pb_2xl]}>
142+
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
143+
{label}
144+
</Text>
145+
<Text emoji style={[a.text_md, a.leading_normal, t.atoms.text]}>
146+
{text}
147+
</Text>
148+
</View>
149+
</View>
150+
)
151+
}

src/lib/api/index.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type AppBskyEmbedRecordWithMedia,
77
type AppBskyEmbedVideo,
88
AppBskyFeedPost,
9+
AtUri,
910
BlobRef,
1011
type BskyAgent,
1112
type ComAtprotoLabelDefs,
@@ -21,6 +22,7 @@ import {sha256} from 'js-sha256'
2122
import {CID} from 'multiformats/cid'
2223
import * as Hasher from 'multiformats/hashes/hasher'
2324

25+
import {type EditedPostRecord} from '#/lib/edit-post'
2426
import {isNetworkError} from '#/lib/strings/errors'
2527
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
2628
import {logger} from '#/logger'
@@ -200,6 +202,75 @@ export async function post(
200202
return {uris}
201203
}
202204

205+
export class AlreadyEditedError extends Error {
206+
constructor() {
207+
super('This post has already been edited')
208+
}
209+
}
210+
211+
/**
212+
* Edits a post's text, keeping its embed and other fields. The old text moves
213+
* into `originalText` and `updatedAt` is stamped.
214+
*
215+
* We delete and recreate under the same rkey (one atomic applyWrites) rather
216+
* than putRecord, because the app view ignores in-place updates to posts - the
217+
* edit just wouldn't show up. The catch: the recreate reads as a new post, so
218+
* likes/reposts/replies reset. Fine inside the short edit window, when there's
219+
* barely any engagement yet.
220+
*/
221+
export async function editPost(
222+
agent: BskyAgent,
223+
{uri, richtext}: {uri: string; richtext: RichText},
224+
) {
225+
const urip = new AtUri(uri)
226+
const existing = await agent.com.atproto.repo.getRecord({
227+
repo: urip.host,
228+
collection: 'app.bsky.feed.post',
229+
rkey: urip.rkey,
230+
})
231+
if (!AppBskyFeedPost.isRecord(existing.data.value)) {
232+
throw new Error('Record is not a post')
233+
}
234+
const record = existing.data.value as EditedPostRecord
235+
if (typeof record.updatedAt === 'string') {
236+
throw new AlreadyEditedError()
237+
}
238+
239+
const rt = await resolveRT(agent, richtext)
240+
const updated: EditedPostRecord = {
241+
...record,
242+
text: rt.text,
243+
facets: rt.facets,
244+
originalText: record.text,
245+
updatedAt: new Date().toISOString(),
246+
}
247+
248+
const writes: (
249+
| $Typed<ComAtprotoRepoApplyWrites.Delete>
250+
| $Typed<ComAtprotoRepoApplyWrites.Create>
251+
)[] = [
252+
{
253+
$type: 'com.atproto.repo.applyWrites#delete',
254+
collection: 'app.bsky.feed.post',
255+
rkey: urip.rkey,
256+
},
257+
{
258+
$type: 'com.atproto.repo.applyWrites#create',
259+
collection: 'app.bsky.feed.post',
260+
rkey: urip.rkey,
261+
value: updated,
262+
},
263+
]
264+
265+
await agent.com.atproto.repo.applyWrites({
266+
repo: urip.host,
267+
validate: true,
268+
writes,
269+
})
270+
271+
return {uri}
272+
}
273+
203274
async function resolveRT(agent: BskyAgent, richtext: RichText) {
204275
const trimmedText = richtext.text
205276
// Trim leading whitespace-only lines (but don't break ASCII art).

0 commit comments

Comments
 (0)