๐๐ป ์ด๋ฒ์๋ ๋ถ์คํธ์ ๊ตฌ์กฐ์ ๋ํด์ ์ดํด๋ด
๋๋ค.
This time, we will take a look at the structure of Boost.
๐๐ป ์ด์ ์ ์ข์์ ๋ถ๋ถ์ ๋ก์ง๊ณผ ๋งค์ฐ ์ ์ฌํ๊ณ ์ฝ๋์ ์ฌ์ฉ์์น๋ ๊ฑฐ์ ๋์ผํฉ๋๋ค.
The logic is very similar to that of the previous ‘like’ feature, and the code is used in almost the same place.
๐ ClientPage.tsx
โ๏ธ app/usersui/[username]/ClientPage.tsx
โ๏ธ ๋ถ์คํธ ๋ถ๋ถ์ ์ข์์ ๋ถ๋ถ์ ๊ธฐ๋ฅ๊ณผ ๋งค์ฐ ์ ์ฌํฉ๋๋ค.
The “Boost” feature is very similar in functionality to the “Like” feature.
... ...
const handleBoost = async (post: any) => {
const latest = timelineRef.current.find((p: any) => p.id === post.id) || post;
let target = latest.original_id || latest.id;
if (!target.startsWith('http')) {
if (latest.actor && latest.actor.startsWith('http')) {
try {
const actorUrl = new URL(latest.actor);
const parts = actorUrl.pathname.split('/');
const usersIdx = parts.indexOf('users');
const actorUsername = usersIdx!== -1? parts[usersIdx + 1] : latest.username;
target = `${actorUrl.origin}/users/${actorUsername}/statuses/${target}`;
} catch {
target = `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${latest.username}/statuses/${target}`;
}
} else {
target = `https://${process.env.NEXT_PUBLIC_DOMAIN || 'aloy-horizon.duckdns.org'}/users/${latest.username}/statuses/${target}`;
}
}
const currentlyBoosted = latest.isMyBoost;
console.log('๐ boost click', { target, isMyBoost: currentlyBoosted });
// Optimistic
setTimeline(prev => prev.map((p: any) =>
p.id === latest.id
? {...p, isMyBoost:!currentlyBoosted, boostCount: (p.boostCount || 0) + (currentlyBoosted? -1 : 1) }
: p
));
try {
const res = await fetch(`/api/announce`, {
method: currentlyBoosted? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, target }),
cache: 'no-store'
});
const data = await res.json();
console.log('โ
announce res', data);
// โ
์๋ฒ ์ง์ค๊ฐ์ผ๋ก ๋ฎ์ด์ฐ๊ธฐ
// Overwrite with server truth values
setTimeline(prev => prev.map((p: any) =>
p.id === latest.id
? {...p, isMyBoost: data.isMyBoost, boostCount: data.boostCount }
: p
));
if (data.alreadyBoosted || data.alreadyUnboosted) {
await refreshTimeline();
}
} catch (e) {
console.error('โ boost ์คํจ/boost failed', e);
await refreshTimeline();
}
};
... ...
1)์ด handleboostํจ์๊ฐ TimelineList.tsx์ ์ ๋ฌ๋๊ณ TimelineList.tsx์์ ๋ถ์คํธ ๋ฒํผ์ ๋๋ฅด๋ฉด ์ด handleboostํจ์๊ฐ ์คํ๋ฉ๋๋ค.
The handleboost function is passed to TimelineList.tsx, and when the boost button is pressed within TimelineList.tsx, this handleboost function is executed.
2)target์ API์ ์์ฒญํ ์ ์ฒด URL์ ๋ง๋ญ๋๋ค.
‘target’ constructs the full URL for the API request.
3)์ฝ๋์๋์ ๋ณด๋ฉด ๋ค์๊ณผ ๊ฐ์ ๋ฐฉ์์ผ๋ก target๊ณผ username์ API์ ์ ์กํฉ๋๋ค.
Below the code, you can see how the target and username are sent to the API.
const res = await fetch(`/api/announce`, {
method: currentlyBoosted? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, target }),
cache: 'no-store'
});
4)actorUrl์ ๋ค์๊ณผ ๊ฐ์ ๊ณผ์ ์ ๊ฑฐ์นฉ๋๋ค.
actorUrl goes through the following process:
1.actorURL์์ pathname๋ง ์ ํํฉ๋๋ค.
Select only the pathname from actorURL.
const actorUrl = new URL(latest.actor);
// actorUrl.origin = "https://mastodon.social"
// actorUrl.pathname = "/users/bob"
2.๊ทธ๋์ ์๋์ฒ๋ผ / ๊ธฐ์ค์ผ๋ก ๊ฐ์ ๋ถ๋ฆฌํ๊ณ
So, as shown below, split the values โโbased on the / character.
const parts = actorUrl.pathname.split('/');
// parts = ["", "users", "bob"]
// 0 1 2
3.๋ฐฐ์ด์์ users๋ผ๋ ๋ฌธ์๊ฐ ํฌํจ๋ ์ธ๋ฑ์ค๋ฅผ ๋ฝ๊ณ
Extract the indices from the array that contain the string "users"
const usersIdx = parts.indexOf('users');
// usersIdx = 1
4. ๋ฐฐ์ด์ด -1์ด ์๋๋ฉด actorUsername์ ๋ฐฐ์ด๊ฐ์ค[usersIdx + 1]๋ฅผ ๋์
ํฉ๋๋ค.
If the array is not -1, assign the value at index `usersIdx + 1` from the array to `actorUsername`.
const actorUsername = usersIdx !== -1 ? parts[usersIdx + 1]
// parts[1 + 1] = parts[2] = "bob"
// actorUsername = "bob"
5.URL๊ฐ์ฒด ์ ๋ณด / URL object information
actorUrl.href = "https://mastodon.social/users/bob" // ์ ์ฒด / entire
actorUrl.protocol = "https:" // https: ๋ง ๋ฐ๋ก / 'https:' only
actorUrl.host = "mastodon.social" // ๋๋ฉ์ธ๋ง ๋ฐ๋ก / Domain only
actorUrl.origin = "https://mastodon.social" // https + ๋๋ฉ์ธ / https + domain
actorUrl.pathname = "/users/bob" // ๊ฒฝ๋ก๋ง ๋ฐ๋ก / The path only, separately
5) ์์ ๊ณผ์ ์ผ๋ก ์๋์ ๊ฐ์ fullURL์ ๋ง๋ญ๋๋ค.
Through the process above, a fullURL like the one below is created.
target = `${actorUrl.origin}/users/${actorUsername}/statuses/${target}`;
6) timeline๋ฐ์ดํฐ์์ ํด๋น ๊ธ์ isMyBoost๊ฐ๊ณผ boostCount๊ฐ์ ๋ฐ๊ฟ์ TimelineList.tsx์์ ๋ณด์ฌ์ฃผ๋ ๊ฐ๋ค์ ๋จผ์ ๋ฐ๊ฟ๋๋๋ค. ์ด ๋ถ๋ถ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ฐ์ด ๋ฐ๋๊ธฐ ์ ์ UI๋ฅผ ๋จผ์ ๋ฐ๊ฟ์ฃผ๋ ๋ถ๋ถ์
๋๋ค.
์ด๊ฒ์ ๋๊ด์ ์
๋ฐ์ดํธ๋ผ๊ณ ํฉ๋๋ค. prev๋ ๊ธฐ์กด์ timeline์ ๋ฐ์ดํฐ์
๋๋ค.
First, update the isMyBoost and boostCount values โโfor the relevant post within the timeline data, which will in turn update the values โโdisplayed in TimelineList.tsx.
In this part, the UI is updated before the database value changes.
This is called an optimistic update.
‘prev’ represents the data from the existing timeline.
setTimeline(prev => prev.map((p: any) =>
p.id === latest.id
? {...p, isMyBoost:!currentlyBoosted, boostCount: (p.boostCount || 0) + (currentlyBoosted? -1 : 1) }
: p
));
7)์ด ๋ถ๋ถ์์ ์ค์ ๋ก API์ ํต์ ํด์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ฐ์ ๋ฐ๊ฟ๋๋ค ๊ทธ๋ฆฌ๊ณ ํ๋ฒ๋ setTimeline()์ผ๋ก ํ์๋ผ์ธ ๋ฐ์ดํฐ๋ฅผ ์
๋ฐ์ดํธํฉ๋๋ค. ๋๊ด์ ์
๋ฐ์ดํธ ํ ํ๋ฒ๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ง์ง ๋ฐ์ดํฐ๋ก ์
๋ฐ์ดํธ ํฉ๋๋ค.
At this stage, the application communicates with the API to modify values โโin the database, and then calls setTimeline() once more to update the timeline data. In other words, following an “optimistic update,” the data is refreshed again using the actual, authoritative data from the database.
const res = await fetch(`/api/announce`, {
method: currentlyBoosted? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, target }),
cache: 'no-store'
});
const data = await res.json();
console.log('โ
announce res', data);
// โ
์๋ฒ ์ง์ค๊ฐ์ผ๋ก ๋ฎ์ด์ฐ๊ธฐ
// Overwrite with server truth values
setTimeline(prev => prev.map((p: any) =>
p.id === latest.id
? {...p, isMyBoost: data.isMyBoost, boostCount: data.boostCount }
: p
));
8)์์ ๋ถ์คํธ ๊ณผ์ ์ด ๋๋ฌ์ผ๋ฉด ํ์๋ผ์ธ ์ ์ฒด๋ฅผ ํ๋ฒ ์
๋ฐ์ดํธํฉ๋๋ค.
Once the boost process described above is complete, update the entire timeline.
if (data.alreadyBoosted || data.alreadyUnboosted) {
await refreshTimeline();
}
9)TimelineList.tsx์ ๋ถ์คํธ ๋ถ๋ถ ์ฝ๋๋ ์๋์ ๊ฐ์ต๋๋ค.
The code for the ‘boost’ section in TimelineList.tsx is as follows:
<div className="flex gap-4 mt-2">
<button onClick={() => onBoost(p)} className={`bg-none border-none cursor-pointer text-[#999] px-2 py-1 rounded-xl text-sm hover:bg-[#f3f4f6] hover:text-[#333] ${p.isMyBoost? '!text-[#1b9c59] font-bold bg-[#dcfce7]' : ''}`}>๐ {p.boostCount || ''}</button>
... ...
</div>
๐ API
โ๏ธ POST
— ๋ถ์คํธ ํ ๊ธ์ด ๋ด ์๋ฒ ๊ธ์ด ์๋ ๊ฒฝ์ฐ
When the post you want to boost is not from your own server
... ...
if (!isOwnPost) {
try {
const pr = await signedFetch(target, username);
if (pr.ok) {
const d = await pr.json();
apObjectId = (d.id || target).replace('/posts/', '/statuses/');
const info = await getActorData((d.attributedTo || d.actor) as any, username);
inbox = info.inbox;
}
} catch {}
}
... ...
1)๋ด ๊ธ์ด ์๋๋ฉด target ์์น์ ์๋๋ฐฉ ๊ธ์ ์กด์ฌํ์ธ(await signedFetch(target, username);)
If it is not my own post, verify the existence of the other person’s post at the target location (await signedFetch(target, username);)
2)๊ธ์ด ์กด์ฌํ๋ฉด(pr.ok)getActorData๋ก ์๋๋ฐฉ inbox์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
If the post exists (pr.ok), retrieve the other party’s inbox information using getActorData.
— ์ด์ ์ ๋ถ์คํธ ํ ์ ์ด ์๋์ง ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ๊ฒ์
Search the database to see if there has been a previous boost
const existing = db.prepare(
`SELECT id FROM announces WHERE username =? AND object LIKE '%' ||? || '%'`
).get(username, shortId) as any;
1)shortId๊ฐ ํฌํจ๋ ๊ธ๊ณผ username๊ณผ ์ผ์นํ๋ id ๊ฒ์
Search for a post containing a shortId and an ID that matches the username.
2)LIKE ‘%’ ||? || ‘%’`
'%' = SQL ์์ผ๋์นด๋. "์๋ฌด๊ฑฐ๋"
|| = ๋ฌธ์์ด ๋ถ์ด๊ธฐ
? = shortId
'%' + shortId + '%' ๋ shortId๊ฐ ํฌํจ๋ ๊ธ์ด๋ผ๋ ์๋ฏธ
— ๋ถ์คํธํ ๊ธ์ด ์กด์ฌํ๋ฉด json๊ฐ ๋ฆฌํด
If a boosted post exists, return a JSON value.
if (existing) {
const boostCount = (db.prepare(
`SELECT COUNT(*) as c FROM announces WHERE object LIKE '%' ||? || '%'`
).get(shortId) as any).c;
return NextResponse.json({ ok: true, alreadyBoosted: true, isMyBoost: true, boostCount });
}
1) ์ด์ ์ ๋ถ์คํธ ํ์ ์ด ์๋ค๋ฉด ๋ค์ ๋ถ์คํธํ์ง ๋ชปํ๊ฒ ํจ.
Prevent a user from boosting again if they have already boosted previously.
— ๋ถ์คํธ ๋ฐฐ๋ฌ / Boost Delivery
let announceDoc;
if (isOwnPost) {
announceDoc = { id: `${myActor}/announces/${crypto.randomUUID()}`, actor: myActor, object: apObjectId };
} else {
if (!inbox) return NextResponse.json({ ok: false, error: 'no inbox' }, { status: 400 });
const r = await sendAnnounce(inbox, apObjectId, username);
announceDoc = r.announceDoc;
}
// const myActor = `https://${DOMAIN}/users/${username}`;
// let apObjectId = target.replace('/posts/', '/statuses/');
1)๋ด ์๋ฒ ๊ธ์ด ์๋๋ฉด sendAnnounceํจ์๋ก ๋ถ์คํธ ๋ฐฐ๋ฌ
If the post is not from my own server, deliver a boost using the sendAnnounce function.
2)announceDoc = {id: … }์์ id ๋ถ๋ถ์ ๊ฒฝ๋ก๋ ์ค์ ๋ก ์กด์ฌํ์ง ์์ ๊ทธ๋ฅ ๋ถ์คํธ ์๋ณ์ฉ id์.
In announceDoc = {id: ... }, the path represented by the id part does not actually exist; it serves merely as an identifier for the boost.
3)์ง๊ธ์ ์ ํ ๋ฌธ์ ์์ผ๋ ์ค์ ๋ก ์ ๋๋ก ํ์ฉํ๋ ค๋ฉด ๋ผ์ฐํธ ์์ด์ผํจ.
There are no issues at the moment, but to make proper use of it, you will need a router.
db.prepare('INSERT OR IGNORE INTO announces (id, actor, object, username) VALUES (?,?,?,?)')
.run(announceDoc.id, myActor, announceDoc.object.replace('/posts/', '/statuses/'), username);
const boostCount = (db.prepare(
`SELECT COUNT(*) as c FROM announces WHERE object LIKE '%' ||? || '%'`
).get(shortId) as any).c;
return NextResponse.json({ ok: true, isMyBoost: true, boostCount });
1) ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ถ์คํธ ์ ์ฅ ๋ถ์คํธ ์นด์ดํฐ ๊ฒ์
Retrieve boost count from the database
2)๊ฒฐ๊ณผ๊ฐ json๋ฐ์ดํฐ ๋ฆฌํด
Return the result as JSON data
{ ok: true, isMyBoost: true, boostCount }
โ๏ธ DELETE
— ๋ถ์คํธ ๊ฒ์ / Boost Search
const myActor = `https://${DOMAIN}/users/${username}`;
const shortId = target.replace('/posts/', '/statuses/').split('?')[0].split('#')[0].split('/').filter(Boolean).pop() || target;
const row = db.prepare(
`SELECT id, object, actor FROM announces WHERE username =? AND object LIKE '%' ||? || '%'`
).get(username, shortId) as any;
if (!row) {
const boostCount = (db.prepare(
`SELECT COUNT(*) as c FROM announces WHERE object LIKE '%' ||? || '%'`
).get(shortId) as any).c;
return NextResponse.json({ ok: true, alreadyUnboosted: true, isMyBoost: false, boostCount });
}
1)username๊ณผ shortId๊ฐ ์ผ์นํ๋ ๋ถ์คํธ๊ฐ ์กด์ฌํ๋์ง ๊ฒ์
Search for the existence of a boost that matches both the username and shortId.
2)์กด์ฌํ์ง์์ผ๋ฉด ๋ค์๊ณผ ๊ฐ์ json๋ฐ์ดํฐ ๋ฆฌํด
If it does not exist, return the following JSON data.
{ ok: true, alreadyUnboosted: true, isMyBoost: false, boostCount }
— ๋ถ์คํธ ์ทจ์ ๋ฐฐ๋ฌ / Boost-cancelled delivery
// ๋ถ์คํธ ์ทจ์ ๋ฐฐ๋ฌ / Undo Boost Delivery
const objectForCheck = row.object || target;
const isOwnPost = objectForCheck.includes(`${DOMAIN}/users/${username}/`);
if (!isOwnPost) {
try {
const u = new URL(objectForCheck);
const parts = u.pathname.split('/').filter(Boolean);
const idx = parts.indexOf('users');
const inbox = idx!== -1? `${u.origin}/users/${parts[idx+1]}/inbox` : null;
if (inbox) {
await sendUndoAnnounce(inbox, row.id, objectForCheck, username);
}
} catch (e) {
console.error('Undo Announce ๋ฐฐ๋ฌ ์คํจ', e);
}
}
1)๋ด์ฉ์ POST๋ถ๋ถ๊ณผ ๊ฐ๊ณ !isOwnPost์ธ๊ฒฝ์ฐ sendUndoAnnounce๊ฐ ์คํ๋์ด ๋ถ์คํธ ์ทจ์ ๋ฐฐ๋ฌ๋จ
The content is the same as in the POST section; if !isOwnPost is true, sendUndoAnnounce is executed, and a boost cancellation is delivered.
— ๋ถ์คํธ ์ญ์ / Delete Boost
db.prepare('DELETE FROM announces WHERE id =?').run(row.id);
const boostCount = (db.prepare(
`SELECT COUNT(*) as c FROM announces WHERE object LIKE '%' ||? || '%'`
).get(shortId) as any).c;
return NextResponse.json({ ok: true, isMyBoost: false, boostCount });
1)๊ธ์ id์ ์ผ์นํ๋ ๋ถ์คํธ ์ญ์
Delete the boost that matches the post ID
2)๋ถ์คํธ ๊ฐฏ์ ๊ฒ์ ํ json๋ฐ์ดํฐ ๋ฆฌํด
Return JSON data after retrieving the boost count
{ ok: true, isMyBoost: false, boostCount }
๋งํ๋ณต์ 21์ฅ 22์ / Matthew 21: 22
“๋ํฌ๊ฐ ๊ธฐ๋ํ ๋์ ๋ฌด์์ด๋ ์ง ๋ฏฟ๊ณ ๊ตฌํ๋ ๊ฒ์ ๋ค ๋ฐ์ผ๋ฆฌ๋ผ ํ์๋๋ผ”
“And everything you ask in prayer, believing, you will receive.”